For the complete documentation index, see llms.txt. This page is also available as Markdown.
APIFLASKJWTLINUXRCESQLISSTIWAFWEBXXE

Poppet

Lab (Master) - by Leighlin Gunner Ramsay

The following post by 0xb0b is licensed under CC BY 4.0


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

Summary

In Poppet, we begin without credentials, identifying a Linux-based web server on port 80 serving the poppet.localdomain. 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.

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.

We identify a web server running on port 80.

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

We add the following entry to our /etc/hosts file.

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

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

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

We edit our entry in the /etc/hosts file as follows.

First, we visit each individual vhost using our browser.

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

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.

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

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.

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.

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.

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

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.

We are able to identify the following tables:

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:

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.

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

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.

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

We are able to successfully authenticate as helpdesk.

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.

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.

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

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

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.

Next, we provide the reset code.

The password of the warehouse_mgr user has been successfully reset.

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

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.

We are updating our /etc/hosts entry again.

Access as j.martinez on payroll.poppet.local

First, we visit the Connect API and provide the API Key to log in.

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

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.

We move on with the Dispatch API.

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.

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

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.

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.

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

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.

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.

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.

We are able to bypass the filter by using a MySQL conditional comment:

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.

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

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

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.

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

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.

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

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.

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.

We head to the payroll vhost and enter the credentials.

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

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.

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.

And as already mentioned the Position Description is being synced from the CRM employee records.

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.

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.

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.

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.

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

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

We head to the crm endpoint and enter the credentials.

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

Access as j.martinez on crm.poppet.local

We see a JWT session cookie is being used.

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

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.

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.

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

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.

Access as toybot on poppet.local

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

And the changes are also here visible and reflected.

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

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

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.

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

Unfortunately request is being blocked.

We try some other and are successful with url_for.

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

But attr is not the reason why it fails.

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

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

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

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.

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

WE'll focus on the toybot user.

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

... and are able to authenticate as toybot.

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.

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

We upload the file, but receive an error.

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

And upload the utf16 version.

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

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

Bonus - Access as payroll_admin on payroll.poppet.local

We recall the env variable that was leaked via SSTI.

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.

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

Last updated