VaultSpace Revenge

Code Review

api_handler.php

What it does: It accepts POST requests. If the body is JSON, it reads action and params. Then it calls a function named by action (with params as arguments) and returns the result as JSON.

// api_handler.php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $contentType = $_SERVER['CONTENT_TYPE'] ?? '';
    if (strpos($contentType, 'application/json') !== false) {
        $input  = json_decode(file_get_contents('php://input'), true);
        $action = $input['action'] ?? '';
        $params = $input['params'] ?? [];
    } else {
        $action = $_POST['action'] ?? '';
        $params = $_POST['params'] ?? [];
    }

    $result = safe_call_function($action, $params);
    echo json_encode(['status' => 'success', 'data' => $result]);
}

Why it’s vulnerable: There’s no authentication or authorization here. If I know the name of an internal helper function that matches the expected pattern, I can execute it as an unauthenticated user and get its return value back as JSON. That turns this endpoint into a capabilities vending machine.

functions.php

safe_call_function()

What it does: It only allows functions whose names start with get_, check_, or set_, verifies they exist, then calls them.

Why it’s vulnerable: The regex stops you from calling random PHP built-ins like system, sure. But it doesn’t check who is calling what. Any exported helper that matches the naming rule becomes public. That’s not access control; it’s just a naming convention. In other words, once /api_handler.php is reachable, every “get_/check_/set_*” function is effectively public.

get_file_details()

What it does: Looks up a record by file_name from a caller-supplied “table name”, returns the first column of the row.

Identifier injection: Values can be bound with ?, but identifiers (table/column names) cannot. Here, $tablename is concatenated directly into SQL. That lets an attacker pass a crafted subquery instead of a plain table name. Example shape we used:

This creates a fake “table” with a column that contains the admin’s reset code.

“Magic index” return:

The function returns $data[0]. With PDO::FETCH_ASSOC, $data is associative (by column names). But in PHP, if a key is the string "0", $data[0] will happily return it. So if we alias a column as `0`, the function will return whatever value we pack into that column. That’s why the subquery above names the secret column `0`.

Building the payload

What the API expects

The vulnerable helper is effectively:

Inside, it runs:

and returns the first column of the returned row (via $data[0]

So if you pass:

  • tablename = uploads

  • file_name = "logo.png"

it would select from the real table uploads and return a field from that row.

Our Trick

SQL lets you use a derived table (a subquery that pretends to be a table):

  • It creates a pretend table t with one row and two columns:

    • file_name = 'a' (just a constant string)

    • `0` = the admin’s reset code (selected from the real users table)

So our “table” looks like this in memory:

file_name

0

a

<admin_reset_code>

We chose file_name = 'a' so it will match the WHERE file_name = ? filter.

Why alias the secret column as `0`?

Because the PHP code returns $data[0].

When PDO fetches rows as an associative array, it looks like:

In PHP, $row[0] will return the value of the '0' key. So by naming the secret column `0`, we guarantee the function returns exactly the secret.

Final payload

  • params[0] → becomes <tablename> in the SQL. We supply our fake table.

  • params[1] → becomes the ? value for file_name. We pass "a" to match the row.

The database executes:

The row comes back as {'file_name': 'a', '0': '<code>'} → the PHP returns $row[0]<code>.

Exploitation steps

Step 1 — request a reset code for admin

We don’t need email. We just need the server to generate a valid, fresh code for the admin account and put $_SESSION['reset_user'] = 'admin' into our session so /reset.php it will accept us as the one performing the reset.

Keep the same PHPSESSID for the whole flow. The reset page checks your session to confirm you initiated the reset.

Step 2 — Read the secret code back out via the router

We’ll call get_file_details through the unauthenticated router and turn the table name into a derived table that manufactures two columns:

  • 'a' AS file_name (so the WHERE file_name = ? binds cleanly), and

  • (SELECT code FROM users WHERE username='admin') AS \0`` (our secret, aliased as backtick-zero).

What the SQL becomes:

What the PHP returns: $row[0] → the value of column `0`the admin’s reset code. The API wraps it as JSON.

Last updated