Blog

From the Shadows: The “Internal Transfer” That Wasn’t

0 Mins Read

·

Monday, August 25, 2025

Brad Geesaman

Principal Security Engineer

The Internal Transfer That Wasn't

The Internal Transfer That Wasn't

The Internal Transfer That Wasn't

What if your banking app let you move money between “your” accounts… except the source account wasn’t actually yours? No special tools required. Just a perfectly normal request that the API shouldn’t have accepted.

This is a classic Broken Object Level Authorization (BOLA) that hides in plain sight because the endpoint looks like it honors the desired business logic—yet quietly violates the application’s intent: you may transfer funds only between accounts you own.

Intent vs. Implementation

Product intent:

A signed-in customer may transfer funds between their own accounts.

That implies two ownership checks:

  1. The source account must be owned by the current user.

  2. The destination account must be owned by the current user.

Miss either one, and “internal transfer” turns into raiding another customer’s balance.


Here's a look at what a user sees for making an account transfer.


Shining a Light on this Flaw

Here’s a shortened version of the vulnerable handler’s code. See if you can spot the flaw:


func MakeTransfer(c *gin.Context) {
    currentUser, err := helpers.GetCurrentUser(c)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"detail": "invalid user"})
        return
    }

    db := database.DB
    var input MakeTransferInput
    var source, dest models.Account

    if err := c.ShouldBindJSON(&input); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
        return
    }

    // validate the source account
    res := db.Where(&models.Account{
        Token:     input.AccountFrom,
        Partition: currentUser.Partition,
    }).First(&source)
    if res.Error != nil {
        c.JSON(http.StatusNotFound, gin.H{"detail": "source account does not exist"})
        return
    }

    // validate the destination account
    res = db.Where(&models.Account{
        Token:     input.AccountTo,
        UserId:    currentUser.ID,
        Partition: currentUser.Partition,
    }).First(&dest)
    if res.Error != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"detail": "not owner of destination account"})
        return
    }

    if source.Balance < input.Amount {
        c.JSON(http.StatusPaymentRequired, gin.H{"detail": "insufficient balance in source account"})
        return
    }

    if input.Amount > transferLimit {
        c.JSON(http.StatusForbidden, gin.H{"detail": "transfer limit exceeded"})
        return
    }

    if input.Amount < 1 {
        c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid transfer amount"})
        return
    }

    if input.AccountFrom == input.AccountTo {
        c.JSON(http.StatusMethodNotAllowed, gin.H{"detail": "can't transfer to same account"})
        return
    }
    // update balances + write transactions...
    // (omitted for brevity—nothing exotic here)

Did you catch it? The destination query validates account ownership (UserId == currentUser.ID), but the source query does not. If a user can guess or learn another account’s ID, they transfer money from another user’s account into their own—no special tricks required.

It’s the kind of oversight that slips past a code review easily, because the query looks correct in isolation. And if you tried to detect this with a static pattern matcher, what would you even look for? There’s no dangerous function call or obvious red flag—only a missing relationship check that reveals itself when you compare the code against the intended business rule.

How to Exploit this?


  1. Sign in as a valid user, e.g. User A.

  2. Enumerate or guess User B’s account ID.

  3. Send POST /api/v3/transfer with account_from = B’s account, account_to = A’s account, and a valid amount.

  4. The API approves the transfer because it never checked that A owns the source account.

No bypasses, no undefined behavior—just a missing authorization constraint.

A Legitimate Transfer Request


$ curl -s '<https://api.ghostbank.net/api/v3/transfer>' \\
  -X POST \\
  -H 'accept: application/json' \\
  -H 'content-type: application/json' \\
  -b 'ghostbank=<session token>' \\
  --data-raw '
  {
    "account_to":696,   // my checking account ID
    "account_from":982, // my savings account ID
    "amount":50
  }'
  
{"status":"ok"}
# no error, money moved between my accounts

Attacker’s Request


$ curl -s '<https://api.ghostbank.net/api/v3/transfer>' \\
  -X POST \\
  -H 'accept: application/json' \\
  -H 'content-type: application/json' \\
  -b 'ghostbank=<session token>' \\
  --data-raw '
  {
    "account_to":696,   // attacker enumerates/guesses my checking account ID
    "account_from":544, // attackers checking account ID
    "amount":50
  }'
  
{"status":"ok"}
# no error, money stolen from my account

The Fix

Below is the logic for the source account lookup. The only change needed is a single added line that aligns the code with the product intent (ownership at both ends).


// find source account
res := db.Where(&models.Account{
    Token:     input.AccountFrom,
+   UserId:    currentUser.ID, // require source ownership
    Partition: currentUser.Partition,
}).First(&source

That’s it. One additional check ensures the source account belongs to the logged-in user, restoring the true “internal transfer” guarantee.

Why This Hides from Legacy Scanners

Pattern matchers hunt for dangerous calls or tainted data flows. Here, the code “looks fine” locally:

  • Authentication exists ✅

  • JSON validated ✅

  • Transfer limits enforced ✅

The flaw only appears when you model the business rule across the handler: both accounts must be owned by the current user. That’s intent-aware security—and it’s where Ghost excels. We follow the logic and relationships between objects to surface vulnerabilities that aren’t fingerprints, they’re broken promises.

Takeaways for Developers


  • Encode intent as code: require ownership (or the right relationship) for every resource you touch.

  • Validate both sides of two-party operations.

  • When you implement logic, try thinking like an attacker and see if you can break your own guardrails.

  • Add specific tests to validate this kind of anti-behavior is handled and responds with an error that doesn’t give an attacker additional information.

Stay tuned for the next edition of From the Shadows, where we’ll shine a light on another subtle flaw lurking just beneath the surface of everyday application logic.

If you’d like to see how Ghost uncovers these kinds of issues before attackers do, sign up here.

Step Into The Underworld Of
Autonomous AppSec

Step Into The Underworld Of
Autonomous AppSec

Step Into The Underworld Of
Autonomous AppSec

Ghost Security provides autonomous app security with Agentic AI, enabling teams to discover, test, and mitigate risks in real time across complex digital environments.

Join our E-mail list

Join the Ghost Security email list—where we haunt vulnerabilities and banish breaches!

© 2025 Ghost Security. All rights reserved

Ghost Security provides autonomous app security with Agentic AI, enabling teams to discover, test, and mitigate risks in real time across complex digital environments.

Join our E-mail list

Join the Ghost Security email list—where we haunt vulnerabilities and banish breaches!

© 2025 Ghost Security. All rights reserved

Ghost Security provides autonomous app security with Agentic AI, enabling teams to discover, test, and mitigate risks in real time across complex digital environments.

Join our E-mail list

Join the Ghost Security email list—where we haunt vulnerabilities and banish breaches!

© 2025 Ghost Security. All rights reserved