Blog
From the Shadows: The “Internal Transfer” That Wasn’t
·
Monday, August 25, 2025

Brad Geesaman
Principal Security Engineer
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:
The source account must be owned by the current user.
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:
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?
Sign in as a valid user, e.g. User A.
Enumerate or guess User B’s account ID.
Send
POST /api/v3/transfer
withaccount_from = B’s account
,account_to = A’s account
, and a valid amount.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
Attacker’s Request
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).
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.