LLMs and Code Review - CTF Challenge

Greetings everyone, In this write-up I tried to utilize Claude Code in reviewing a simple codebase -CTF challenge- to walk me through the codebase, identifying security controls, attack vectors and some other things that would usually take some time If I would do it manually.

Introduction

The challenge is from fahmesec platform and it's a simple PHP web app that acts as a vulnerable bank. Claude code works in our terminal through the prompts we provide. I used it -as will be shown- to define the app workflow, security controls, functions that lack these controls and more. And here is the challenge structure (codebase)

Information Gathering

One of the most boring steps in a code review challenge -or code review generally- is to understand what the application does? As it requires to go through all the files, connect the leads and see how each function is being called or behave , etc ... which would waste me a lot of time. So I've always wondered if there is something that could tell me the high-level overview of what this app/code does, And here comes the first role of Claude. This simple prompt: What is the basic user flow from this codebase ? allowed Claude to go through the codebase files and understand what is the user flow that we should take as normal users (very important step before even trying to hack).

Here we can see that it gave me a very high-level of what the normal user should do and also the file responsible for each flow. It also gave me different operations the user can do in addition to the premium feature/s and it's condition/s.

Identifying Attack Vectors

One of the things I learnt from CBBH (Critical BugBounty Hunting) Podcast is to ask Claude about the security controls present in the application and these could be -for example- Authorization Middleware, Input Filtering/Validation and more. Then we should ask it which functions in the application that lack these controls. For example, If we have security control which is Prepared Statements to prevent SQLi and then we found that a certain function lack this security control by using raw queries, In this case we should obviously validate and try to exploit but it's amazing to find something that help you identify these.

I used this prompt:

What are security controls present in the app ? And how these controls are present in all of transfer, deposit and
  withdraw functions ?

I've make it specific to the functions that I want, In this case these are the only main banking functions but in other cases it would be helpful to make it specific. And here is Claude output:

By reading these, 2 ideas came to my mind:

  1. Is the deposit function disabled from the UI only ? Could we call it from the code using a forgotten endpoint?

  2. Since the transfer function relies on an external API, could we use it to bypass certain filters ? like balance check or the transfer parameters ? (sender, amount & receiver).

Normally I would go through the code to check for these, but since we have this super soldier under our control let's ask it:

Can we call the deposit function from the code directly without relying on the UI? And for the transfer function,
  How is security controls applied on the external API that handles it ?

For the first part, this was the output:

And I found that it was actually commented in the mentioned file so it's not very functional:

As for the second part this was the output:

This seems very interesting for me to investigate, As it partially ensures my idea. We can use this external API to call without PHP and then bypass the security controls like balance check and sender identity to ........ to what ? And here I remembered a very important thing, I don't know what my goal is so I tried to ask Claude about the goal of the challenge in the following format: So what is my objective to get the flag to solve the challenge ? And here is the output:

So it's pretty simple, Be a premium user (by having balance > 10000000) -> Create a sub-user that has account number < 10,000,000 -> The flag will be the username.

Now we have a very basic overview of what we should do: Try to abuse this external API using the identified issues (like the no balance check) which would grant us the premium feature and then create a sub-user with account number < 10,000,000 to get the flag.

Manual Code Review and Application Testing

As the write-up name says, It's LLMs and Code Review not LLMs or Code Review so despite this powerful tool it's essential to manually review the code to validate, identify FPs or even exploiting logic bugs that LLMs usually miss.

Let's first review the TransactionService.php file as it's the gate to the internal API URL.

public function transfer($fromAccount, $toAccount, $amount,$reference) {
        $fromAccount = intval($fromAccount);
        $toAccount = intval($toAccount);
        $amount = intval($amount);

        if ($amount <= 0) {
            http_response_code(400);
            echo json_encode(["Status" => "failed", "Msg" => "Amount must be greater than 0"]);
            return;
        }
        
    
        $internalApiUrl = "http://internal-services:5000/transfer";

        
    
        $url = "$internalApiUrl?reference_number=$reference&from_account=$fromAccount&to_account=$toAccount&amount=$amount";
    
        $ch = curl_init();
    
        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
        ]);
    
        $response = curl_exec($ch);
    
        if (curl_errno($ch)) {
            http_response_code(500);
            echo json_encode(["Status" => "failed", "Msg" => "Internal API error: " . curl_error($ch)]);
            curl_close($ch);
            return;
        }
    
        $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
    
        if ($statusCode !== 200) {
            http_response_code($statusCode);
            echo json_encode(["Status" => "failed", "Msg" => "Transfer failed with code $statusCode"]);
            return;
        }

        if($statusCode === 200){
            $this->checkPremium($fromAccount);
            $this->checkPremium($toAccount);
        }
    
        return $response;
    }

This is the code snippet for the transfer function, And it simply constructs the URL as :

$internalApiUrl = "http://internal-services:5000/transfer";    
$url = "$internalApiUrl?reference_number=$reference&from_account=$fromAccount&to_account=$toAccount&amount=$amount";

And then requests it internally using curl tool.

We do control all the parameters : reference_number, from_account, to_account and amount so let's try to modify these from the request (following the normal flow as in register -> login -> transfer).

We can also notice that the only variable that does not have intval() is the reference and this was mentioned by Claude before:

Here is our dashboard/profile page:

When we use the transfer function we get this screen:

So we have the to_account , amount and reference only displayed, what if manually add the variable from_account in our request to get more money?

We added the variable from_account but it wasn't reflected in the code, as it transfered from my account to my account like if this variable was neglected or unseen. Again, we can go through the code to discover why but let's try to ask Claude: How can I pass the $data['from_account'] = $user->getAccountNumber(); in the request POST /api/transfer.php

Nice, so we can't pass the from_account In the PHP code because this value is being retrieved from the application code using getAccountNumber function not the user input. (I've checked the getAccountNumber() function but nothing can be done here).

Now what we can do ? How can we access the internal API URL without interacting with PHP ? Remember the reference number that lacks the check of it's numeric nature ? Since it's reflected in the URL and we control it we can add the variables directly into it as follow:

And it worked ! We used the reference to control the variables in the internal API URL. Now we need to know what users in the system has huge balance. I asked Claude this question: Are there any users on the system that has balance bigger than 100$ ?

Claude confirmed that the accounts in the DB (attached with the code) has only 100$ not more. But why we need the balance anyway ? We already know from Claude that there is no balance check in this internal API. Let's validate this in the file transfer_service.py :

So the logic here is pretty weird, It starts by selecting the balance of each user (sender and receiver) and then updates the balance directly of both users based on the amount variable without checking the balance. Since we're dealing with the API directly without PHP we can transfer the money we want to our account:

So we messed up the admin account and got the money we need to be a premium user:

Unleashing the Beast - Claude Solving the Challenge

Now we know that in order to solve the challenge we need to create a sub-user with account number less than 10,000,000. But the problem here (as mentioned by Claude) is that the account number generation is always higher than this number because of the rand(10000000, 99999999) in AuthRepository.php .

By creating a subuser I found that we can change his password:

Could this be an IDOR vulnerability allowing us to change the password of other subusers ?

I choose to change the password of subuser of ID 9050 because it has an account number 500 which is less than 10,000,000 and would allow us to get the flag:

Sadly it didn't work, Why ? I asked Claude using this prompt: Ok now since I became a premium user, How do I gain access to the subuser of id 9050? and the answer was:

Nice, so we can't modify his password because we don't own this user and this check happens due to getOwnedUsers() function. Now I tried to ask Claude a very general question Could I change the ownership of a user to another one using a specific function or API endpoint ? , generally It's not best practice to ask very general question like this but it's better to be more specific.

To my surprise this was Claude response:

Wow, this was really amazing. Claude understanded the function logic and provided me with the steps to change the subuser ownership. Not only this, the suggested path is to change subuser name so we can change his ownership ensured that the changing the name function is actually vulnerable to IDOR! so he chained these attacks together too!

Changing the subuser name - We own it now
Changing the subuser password

Now we can login using this password to get the flag:

Final Thoughts

The idea of the challenge could be categorized as easy/medium, But the idea of this write-up not to show how we solved the challenge rather than showing how we could integrate the use of LLMs with a codebase to just arrange our thoughts and make our search process more efficient.

Another point, Most of the codebases in pentesting projects (and maybe bughunting too) have vulnerabilities lying in plain-sight just waiting for someone to discover them, but if the codebase is very large it could be a tedious task to search for every file, every line for a vulnerable snippet. Using the power of LLMs especially something like Claude code could make this task wayyy easier and of course our role as security engineers is to manually review to validate and exploit.

That's it for today and I hope you've learned and enjoyed.

Last updated