Operator On The Wire
← Back to Knowledge Base
OOTW / Chapter III - Web / 03. Command Injection

Example

This example uses the shared Flask lab.

We plug Command Injection in as the third web module.

Complete the Chapter III Introduction setup first. The base app.py, schema.sql, seed.sql, and database.db must already exist.

The flow is:

create modules/command_injection.py
register the blueprint in app.py
restart Flask
visit /command-injection/basic

The Command Injection module provides four pages:

/command-injection/basic
/command-injection/blacklist
/command-injection/better
/command-injection/fixed

Plug In The Module

From the lab directory (disregard if you have already done this):

cd ~/ootw-web-lab
source .venv/bin/activate
mkdir -p modules
touch modules/__init__.py

Create modules/command_injection.py:

cat > modules/command_injection.py <<'EOF'
import re
import subprocess

from flask import Blueprint, abort, redirect, render_template_string, request

from app import get_db


bp = Blueprint("command_injection", __name__, url_prefix="/command-injection")

DEFAULT_TARGET = "localhost"
HOST_RE = re.compile(r"^[A-Za-z0-9.-]{1,253}$")


PAGE = """
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>{{ title }}</title>
    <style>
        body { font-family: system-ui, sans-serif; max-width: 900px; margin: 40px auto; line-height: 1.5; }
        nav a { margin-right: 12px; }
        label { display: block; margin-top: 12px; }
        input { width: 100%; padding: 7px; box-sizing: border-box; }
        button { margin-top: 12px; padding: 7px 10px; }
        pre { background: #111; color: #eee; padding: 12px; overflow-x: auto; }
        code { background: #eee; padding: 2px 4px; }
    </style>
</head>
<body>
    <h1>{{ title }}</h1>

    <nav>
        <a href="/command-injection/basic">basic</a>
        <a href="/command-injection/blacklist">blacklist</a>
        <a href="/command-injection/better">better</a>
        <a href="/command-injection/fixed">fixed</a>
        <a href="/command-injection/audit">audit</a>
    </nav>

    <p>{{ description }}</p>

    <form method="post" action="/command-injection/{{ stage }}">
        <label for="target">Target</label>
        <input id="target" name="target" value="{{ target }}">

        {% if show_label %}
        <label for="label">Label</label>
        <input id="label" name="label" value="{{ label }}">
        {% endif %}

        <button type="submit">Run Diagnostic</button>
    </form>

    {% if command %}
    <h2>Command</h2>
    <pre>{{ command }}</pre>
    {% endif %}

    {% if output is not none %}
    <h2>Output</h2>
    <pre>{{ output }}</pre>
    {% endif %}
</body>
</html>
"""


AUDIT_PAGE = """
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>Command Injection Audit</title>
</head>
<body>
    <h1>Command Injection Audit</h1>
    <p>This page shows recent command execution events from the local lab.</p>
    <p><a href="/command-injection/basic">back to Command Injection lab</a></p>

    {% for event in events %}
    <article>
        <p><strong>{{ event["event_type"] }}</strong> - {{ event["username"] }}</p>
        <pre>{{ event["detail"] }}</pre>
        <small>{{ event["created_at"] }}</small>
    </article>
    {% else %}
    <p>No command injection events yet.</p>
    {% endfor %}
</body>
</html>
"""


def run_shell(command):
    try:
        completed = subprocess.run(
            command,
            shell=True,
            capture_output=True,
            text=True,
            timeout=3,
        )
        output = completed.stdout + completed.stderr
        return completed.returncode, output[:4000]
    except subprocess.TimeoutExpired:
        return -1, "command timed out"


def run_args(args):
    try:
        completed = subprocess.run(
            args,
            shell=False,
            capture_output=True,
            text=True,
            timeout=3,
        )
        output = completed.stdout + completed.stderr
        return completed.returncode, output[:4000]
    except subprocess.TimeoutExpired:
        return -1, "command timed out"


def weak_blacklist(value):
    value = value.replace(";", "")
    value = value.replace("&", "")
    return value


def validate_target(value):
    if not HOST_RE.fullmatch(value):
        abort(400)
    return value


def log_execution(stage, target, command, returncode):
    detail = (
        f"stage={stage} "
        f"target={target} "
        f"returncode={returncode} "
        f"command={command[:500]}"
    )

    db = get_db()
    db.execute(
        """
        INSERT INTO audit_log (event_type, username, detail)
        VALUES (?, ?, ?)
        """,
        (f"command-injection-{stage}", "operator", detail),
    )
    db.commit()


def render_lab(stage, title, description, target, command=None, output=None, label="lookup", show_label=False):
    return render_template_string(
        PAGE,
        stage=stage,
        title=title,
        description=description,
        target=target,
        command=command,
        output=output,
        label=label,
        show_label=show_label,
    )


@bp.get("/")
def index():
    return redirect("/command-injection/basic")


@bp.route("/basic", methods=["GET", "POST"])
def basic():
    target = request.form.get("target", DEFAULT_TARGET)
    command = None
    output = None

    if request.method == "POST":
        command = f"getent hosts {target}"
        returncode, output = run_shell(command)
        log_execution("basic", target, command, returncode)

    return render_lab(
        "basic",
        "Command Injection Basic",
        "The route builds a shell command with the target parameter.",
        target,
        command,
        output,
    )


@bp.route("/blacklist", methods=["GET", "POST"])
def blacklist():
    target = request.form.get("target", DEFAULT_TARGET)
    command = None
    output = None

    if request.method == "POST":
        filtered_target = weak_blacklist(target)
        command = f"getent hosts {filtered_target}"
        returncode, output = run_shell(command)
        log_execution("blacklist", filtered_target, command, returncode)

    return render_lab(
        "blacklist",
        "Command Injection Weak Blacklist",
        "The route removes two shell separators, then still sends the target through a shell.",
        target,
        command,
        output,
    )


@bp.route("/better", methods=["GET", "POST"])
def better():
    target = request.form.get("target", DEFAULT_TARGET)
    label = request.form.get("label", "lookup")
    command = None
    output = None

    if request.method == "POST":
        safe_target = validate_target(target)
        command = f"echo diagnostic {label}; getent hosts {safe_target}"
        returncode, output = run_shell(command)
        log_execution("better", safe_target, command, returncode)

    return render_lab(
        "better",
        "Command Injection Better Target Validation",
        "The route validates the target, but a second field is still interpolated into the shell command.",
        target,
        command,
        output,
        label=label,
        show_label=True,
    )


@bp.route("/fixed", methods=["GET", "POST"])
def fixed():
    target = request.form.get("target", DEFAULT_TARGET)
    command = None
    output = None

    if request.method == "POST":
        safe_target = validate_target(target)
        args = ["getent", "hosts", safe_target]
        command = " ".join(args)
        returncode, output = run_args(args)
        log_execution("fixed", safe_target, command, returncode)

    return render_lab(
        "fixed",
        "Command Injection Fixed",
        "The route validates the target and runs the command as an argument list without a shell.",
        target,
        command,
        output,
    )


@bp.get("/audit")
def audit():
    db = get_db()
    events = db.execute(
        """
        SELECT event_type, username, detail, created_at
        FROM audit_log
        WHERE event_type LIKE 'command-injection-%'
        ORDER BY id DESC
        LIMIT 30
        """
    ).fetchall()
    return render_template_string(AUDIT_PAGE, events=events)
EOF

Now register the module in app.py.

Add this near the bottom of app.py, after the route definitions and before the if __name__ == "__main__": block:

from modules.command_injection import bp as command_injection_bp
app.register_blueprint(command_injection_bp)

If XSS and IDOR are already plugged in, keep those lines and add Command Injection below them:

from modules.xss import bp as xss_bp
app.register_blueprint(xss_bp)

from modules.idor import bp as idor_bp
app.register_blueprint(idor_bp)

from modules.command_injection import bp as command_injection_bp
app.register_blueprint(command_injection_bp)

Run the app:

flask --app app run --host 127.0.0.1 --port 5000

Confirm the module is live:

curl -i "http://127.0.0.1:5000/command-injection/basic"

Basic Vulnerability

Open:

http://127.0.0.1:5000/command-injection/basic

This page takes the target value and builds a shell command:

command = f"getent hosts {target}"
returncode, output = run_shell(command)

The dangerous part is not getent. The dangerous part is shell=True.

Normal request:

curl -i -X POST "http://127.0.0.1:5000/command-injection/basic" \
  -d "target=localhost"

Exploit:

curl -i -X POST "http://127.0.0.1:5000/command-injection/basic" \
  --data-urlencode "target=localhost; id"

The shell sees two commands:

getent hosts localhost
id

The response should include output similar to:

uid=1000(kali) gid=1000(kali)

This proves command execution as the web application process.

![[Chapter III - Web/3. Command Injection/BasicExploit.png]]


Basic Protection - Weak Blacklist

Open:

http://127.0.0.1:5000/command-injection/blacklist

This page removes two obvious separators:

value = value.replace(";", "")
value = value.replace("&", "")

That damages this payload:

localhost; id

It does not make the command safe.

Bypass with a pipe:

curl -i -X POST "http://127.0.0.1:5000/command-injection/blacklist" \
  --data-urlencode "target=localhost | id"

The shell still runs id.

The blacklist blocks two strings. It does not remove the shell parser.

MidExploit


Better Protection - Target Validation

Open:

http://127.0.0.1:5000/command-injection/better

This page validates the target field:

HOST_RE = re.compile(r"^[A-Za-z0-9.-]{1,253}$")

That blocks shell separators inside the target.

Test it:

curl -i -X POST "http://127.0.0.1:5000/command-injection/better" \
  --data-urlencode "target=localhost; id"

Expected:

400 Bad Request

However, the route still places a second field into the shell command:

command = f"echo diagnostic {label}; getent hosts {safe_target}"

Exploit the label field:

curl -i -X POST "http://127.0.0.1:5000/command-injection/better" \
  --data-urlencode "target=localhost" \
  --data-urlencode "label=lookup; id"

The target validation works. The command is still injectable because another user-controlled value reaches the shell.

This is a common remediation failure: the obvious parameter gets protected, but the command string still contains untrusted data.

![[Chapter III - Web/3. Command Injection/Exploit.png]]


Fixed Version

Open:

http://127.0.0.1:5000/command-injection/fixed

The fixed page does two things:

  • It validates the target with a strict allowlist.
  • It runs the command as an argument list without a shell.

The safe call:

args = ["getent", "hosts", safe_target]
subprocess.run(args, shell=False)

Run a normal lookup:

curl -i -X POST "http://127.0.0.1:5000/command-injection/fixed" \
  -d "target=localhost"

Run the original payload:

curl -i -X POST "http://127.0.0.1:5000/command-injection/fixed" \
  --data-urlencode "target=localhost; id"

Expected:

400 Bad Request

There is no shell available to interpret ;, |, $(), or backticks.


Local Impact Proof

Use the vulnerable route to prove local command execution:

curl -i -X POST "http://127.0.0.1:5000/command-injection/basic" \
  --data-urlencode "target=localhost; id; whoami; uname -a"

Then view:

http://127.0.0.1:5000/command-injection/audit

The audit page shows the stage, target, return code, and command string used by the lab.

This proves the useful impact:

stage=basic
target=localhost; id; whoami; uname -a
command=getent hosts localhost; id; whoami; uname -a

The application did not need SQL injection or file upload. It handed user input to a shell.

Audit


Remediation

Patch command injection by removing shell interpretation:

  • Avoid shell=True with user-controlled input.
  • Prefer language libraries over shell commands.
  • Use argument arrays when a subprocess is required.
  • Validate every user-controlled field used near the command.
  • Keep labels, filenames, modes, and debug fields out of command strings.
  • Use strict allowlists, not blacklists.
  • Add timeouts to subprocess calls.
  • Run the web application with least privilege.
  • Log rejected command-like input and suspicious separators.

Safe pattern:

safe_target = validate_target(target)
args = ["getent", "hosts", safe_target]
subprocess.run(args, shell=False, capture_output=True, text=True, timeout=3)

Quoting is weaker than removing the shell. When the command can be represented as a list of arguments, use the list.


Detection

Useful signals include:

  • Requests containing shell separators such as ;, &&, ||, |, $(, or backticks.
  • Requests containing proof commands such as id, whoami, uname, or sleep.
  • /bin/sh errors in responses or logs.
  • Long response times after diagnostic requests.
  • Command output appearing inside HTTP responses.
  • Repeated failures across diagnostic endpoints.
  • Web server processes spawning unexpected child processes.

Useful payload indicators:

; id
| id
&& whoami
|| whoami
$(id)
`id`
sleep 3
/bin/sh:
uid=

Detection is not the fix. It shows which command boundaries attackers are trying to cross.