Note
/api/team?id= validates with /^[0-9]+$/m (multiline → a newline-separated digit line passes) and blocks '/' and '..' via String.includes — but Next.js turns repeated ?id= into an ARRAY, whose .includes() checks elements, not substrings. The read path is sliced to 100 chars, so naive ../ collapses; use the even-length /proc/thread-self/root symlink to land exactly on /flag.txt within the byte budget.
NextPath — array query-param bypass + procfs traversal within a byte budget
Platform: HackTheBox · Category: Web (server-side) · Stack: Next.js 13 (pages/api)
Challenge overview
/api/team?id=<n> reads team/<id>.png. The flag is at /flag.txt. Three guards stand in the way:
const ID_REGEX = /^[0-9]+$/m;
if (!ID_REGEX.test(query.id)) return res.status(400).end("Invalid format");
if (query.id.includes("/") || query.id.includes("..")) return res.status(400).end("...TRAVERSAL...");
const filepath = path.join("team", query.id + ".png");
const content = fs.readFileSync(filepath.slice(0, 100)); // path truncated to 100 bytes!
Step 1 — beat the regex with a newline (the /m flag)
/^[0-9]+$/m is multiline: ^/$ match line boundaries, so the test passes if any line is all
digits. A value like <payload>\n1 satisfies it (%0A1 → the line 1 matches) while letting the rest
of the string be a path.
Step 2 — beat includes("/") / includes("..") with an array
String.includes checks substrings, but Array.includes checks elements. Next.js parses repeated
query keys into an array:
/api/team?id=hello&id=goodbye -> query.id === ['hello','goodbye']
For an array, query.id.includes("/") asks "is "/" an element?" — never true even when an element
contains /. So split the payload across two id= params: the regex still finds a digit-only line in
one element, and the traversal filter is blind to the / and .. inside elements. query.id + ".png"
then stringifies the array (joined with ,) into a usable-ish path.
Step 3 — land on /flag.txt inside the 100-byte slice
fs.readFileSync(filepath.slice(0, 100)) truncates the resolved path to 100 bytes, which chops the
trailing .png only if your prefix length lines up. Spamming ../ works but path.join collapses
../, and the parity rarely lands exactly at 100 with .png removed. The clean fix is the
/proc/.../root symlink, which re-roots without being collapsed — but /proc/self/root is an
odd-length segment that never hits the right boundary. /proc/self resolves to a PID; the
thread-self symlink (proc/thread-self -> <pid>/task/<pid>) gives an even-length
/proc/thread-self/root you can repeat to hit exactly 100 bytes:
GET /api/team?id=1%0A&id=/../../../../../../../../../../../../../../../../../../proc/thread-self/root/proc/thread-self/root/flag.txt
The resolved string is exactly 100 chars, so the .png falls off the end and readFileSync opens
/flag.txt:
HTB{tr4v3r51ng_p45t_411_th3_ch3ck5...t4sk_w3ll_d0ne!}
(Equivalently, fixed PIDs like /proc/1/root + /proc/18/root give the same parity trick.)
Flag: HTB{tr4v3r51ng_p45t_411_th3_ch3ck5...t4sk_w3ll_d0ne!}
Takeaways (generalized techniques)
- Repeated query keys → arrays in Next.js/Express. Any filter using
String.prototype.includes/.startsWith/indexOfbecomes an array method when the field is sent twice, changing substring checks into element checks — a generic bypass for character/traversal blacklists (same family as one-element-array JSON type confusion). /mmultiline regex anchors per line:/^[0-9]+$/mis satisfied bypayload\n123, so a newline (%0A) smuggles arbitrary content past a "digits only" check./proc/<id>/rootand/proc/thread-self/rootre-root the path withoutpath.joincollapsing it, and let you tune the byte length of a traversal so a fixed-sizeslice()/buffer drops a known suffix (here.png). Pickthread-selfvsselfto control even/odd length parity.
Sources & references
- Challenge source:
hackthebox/web/nextpath - procfs
thread-self: https://man7.org/linux/man-pages/man5/proc.5.html