# Unsafe Archive Unpacking: Labs and Semgrep Rules

Archive extraction libraries in Python, Ruby, PHP, Java, Swift and JS write entry names containing ../ outside the intended output directory, enabling arbitrary file overwrite.

- Payout: Research
- Severity: high
- Program: Doyensec Unsafe-Unpacking
- Write-up by: Doyensec
- Disclosed: 2024-12-15
- Write-up quality: 6/10
- Original: https://blog.doyensec.com/2024/12/16/unsafe-unpacking.html
- Library page: https://bugbountylibrary.com/r/blog%3Ablog-doyensec-com-2024-12-16-unsafe-unpacking-html
- Tags: area:oss domain:oss impact:integrity impact:rce kind:research platform:research sev:high tech:php tech:python tech:ruby technique:source-review vuln:file-upload vuln:path-traversal

**Target:** Archive extraction libraries (zipfile/shutil, rubyzip, etc.)
**Root cause:** Extraction APIs accept entry names verbatim and join them to the output path without normalizing or validating, so ../ sequences escape the destination directory.
**Steps:**
1. Craft a zip whose entry name is `../imbad.txt` (or `..././bad.txt` to bypass naive gsub).
2. Python: `output_path = os.path.join(output, filename)` then `shutil.copyfileobj(source, destination)` writes outside output.
3. Ruby: `File.join(file_path, entry.name)` then `zip_file.extract(entry, extraction_path)` writes outside output.
4. Observe poc.txt lands in the parent directory instead of test_case/.
5. Detect with Semgrep rule matching `shutil.copyfileobj(source, destination)` patterns.
**How found:** Researcher audited decompression APIs per language, noticed writer functions take file descriptors or unnormalized paths and never check containment, then built PoCs and Semgrep rules to find the pattern at scale.
**Exploitation:** Attacker supplies a malicious archive to a service that unpacks it; entries with ../ are written outside the extraction root, overwriting sensitive files such as /etc/passwd if the process runs privileged.
**Escalation:** Overwriting files outside the extraction directory can lead to code execution or privilege escalation when privileged paths are writable.
**Insight:** Naive `../` stripping is bypassable with `..././` because a single gsub pass leaves `../`; and `start_with?` checks on non-normalized paths (File.join) or without a trailing separator on the output dir both pass for sibling directories like output_bypass.

## Proof of concept (copied exactly from the original report)

**Vulnerable Python zipfile+shutil extraction PoC**

```
def unzip(file_name, output):
 # bad
 with zipfile.ZipFile(file_name, 'r') as zf:
 for filename in zf.namelist():
 # Output
 output_path = os.path.join(output, filename)
 with zf.open(filename) as source:
 with open(output_path, 'wb') as destination:
 shutil.copyfileobj(source, destination)
 
unzip1(./payloads/payload.zip", "./test_case")
```

**Vulnerable Ruby Zip extract PoC**

```
require 'zip'
 
def unzip1(file_name, file_path)
 # bad
 Zip::File.open(file_name) do |zip_file|
 zip_file.each do |entry|
 extraction_path = File.join(file_path, entry.name)
 FileUtils.mkdir_p(File.dirname(extraction_path))
 zip_file.extract(entry, extraction_path) 
 end
 end
end

unzip1('./payloads/payload.zip', './test_case/')
```

**Semgrep scan command on PoC folder**

```
semgrep scan --config=../../rules/zip_shutil_python.yaml

...

┌─────────────────┐
│ 5 Code Findings │
└─────────────────┘

 zipfile_shutil.py
 ❯❯❱ rules.unsafe_unpacking
 Unsafe Zip Unpacking

 13┆ shutil.copyfileobj(source, destination)
 ⋮┆----------------------------------------
 21┆ shutil.copyfileobj(source, destination)
 ⋮┆----------------------------------------
 31┆ shutil.copyfileobj(source, destination)
 ⋮┆----------------------------------------
 41┆ shutil.copyfileobj(source, destination)
 ⋮┆----------------------------------------
 57┆ shutil.copyfileobj(source_file, target_file)
```

