When Web3 Withdrawals Meet Web2 Logic
5 min read
June 29, 2025

Table of contents
Hey everyone
Over the past couple of weeks, I’ve been working on a few projects involving Web3 deposit and withdrawal flows. And something kept coming up. Even when things look decentralized on the surface, the logic behind the scenes often runs through classic Web2 backends.
That mix is where things start to break.
In this issue we’re going to dig into what can go wrong when blockchain-based systems rely on traditional logic for handling money. We’ll walk through real attack scenarios like race conditions, broken authorization, weak 2FA enforcement, and some clever UI tricks that can turn secure-looking flows into easy targets.
Let’s get into it
Withdrawal Race Condition
Here’s a classic bug that shows up more often than you’d think, especially in hybrid Web3 and Web2 setups.
Even though your withdrawals might end up on-chain, they often start with a Web2-style backend call. Something like a POST /withdraw
that talks to your hot wallet signer.
Now imagine this. The backend checks if your balance is enough before processing the withdrawal, but doesn't lock anything or wrap it in a proper transaction. That’s when race conditions sneak in.
A race condition happens when multiple actions happen at the same time and the system can't keep up with the timing. If two or more requests check the balance at the exact same moment and both see the same number, they might both get approved before anything is updated.
Let’s make it concrete. I have 5 ETH in my account. I fire off 10 withdrawal requests for 1 ETH each, all in parallel. If the backend isn't careful, all of them see 5 ETH, all of them get approved, and suddenly I’ve withdrawn way more than I should.
You can simulate this pretty easily using Turbo Intruder in Burp Suite or the Parallel Repeater. If you see multiple withdrawals going through from a limited balance, congrats, you’ve just uncovered a race condition.
IDOR/BOLA on Withdrawals
This one’s surprisingly common. BOLA (Broken Object Level Authorization), or what many still call IDOR, happens when the backend forgets to check if the user actually owns the thing they’re trying to access. In this case, a withdrawal.
Let’s say your app shows your pending withdrawals like this:
GET /withdrawals/881
Authorization: Bearer your_token
Now imagine I change that to:
GET /withdrawals/882
And suddenly I’m looking at someone else’s withdrawal details. That’s BOLA.
But it doesn’t stop there. What if I go a step further and modify the withdrawal?
PATCH /withdrawals/882
Authorization: Bearer attacker_token
{ "status": "approved", "to": "0xAttackerWallet" }
If the backend doesn’t check who owns withdrawal 882, I just hijacked someone’s funds and redirected them to my wallet.
This usually happens when IDs are predictable and the backend trusts the token too much without validating ownership. To test this, grab a valid request in Burp, increment the ID, and see if you can access or modify someone else’s data.
If it works, you’ve got a serious logic bug.
Skipping 2FA During Withdrawals
On paper, adding 2FA to withdrawals sounds like a solid move. Before sending a large amount, the user has to confirm it with a code sent by SMS, email, or an authenticator app. Makes sense, right?
But here’s the catch. Just because the frontend asks for a 2FA code doesn't mean the backend is actually enforcing it.
Imagine this. I steal your session token. Maybe through phishing, maybe through a SIM swap. When I try to withdraw, the app sends me a nice little 2FA prompt. But instead of entering anything, I skip the UI and go straight to the withdrawal endpoint using the stolen token.
If the backend doesn’t verify whether the 2FA challenge was actually completed and validated, the withdrawal goes through anyway.
Worse, in some systems, the 2FA token field might be optional, reused, or even entirely ignored. That means an attacker could replay an old token or skip the field completely and still get the funds out.
To test this, try sending a withdrawal request:
- Without the 2FA token
- With a reused 2FA code
- While skipping the entire verification step
If any of those work, then your 2FA is only protecting the UI, not the actual logic. And if the backend assumes “if it came from the app, it must be legit,” you're one step away from someone draining accounts without ever touching a code.
Double Execution via Retry
This one feels harmless at first. Some apps separate withdrawal into two steps: first you create the request, then you hit an /execute
endpoint to actually send the funds.
That makes sense. Maybe the backend needs to wait for 2FA approval, compliance checks, or some manual confirmation. But here’s where things break.
If the backend doesn't properly mark the withdrawal as processed before executing it, an attacker can just call the /execute
endpoint again. And again. And again.
Let’s say I request a withdrawal. I get back a withdrawalId
. After it’s approved, the app calls:
POST /withdraw/execute/abc123
Authorization: Bearer stolen_token
Now I take that exact same request and send it ten times in a row. If there’s no protection in place, the backend might process it each time, triggering multiple payouts from a single request.
In some cases, the same withdrawalId
might return a new transaction hash every time. Or even worse, all of them succeed and send funds.
To test this, try delaying the first execution slightly. Then spam the same endpoint in parallel using Turbo Intruder or Parallel Repeater. If the backend doesn’t lock or update the withdrawal status before sending the funds, you’ll see multiple transactions.
This kind of bug is sneaky. It often hides in systems where developers assume the /execute
endpoint will only be called once by the frontend.
Address Poisoning via 2FA Approval Metadata
This one’s more social engineering than technical exploit, but it’s just as dangerous.
Many apps show a 2FA prompt before approving a withdrawal. The user sees a message like “Approve withdrawal of 10 ETH to 0x1234...abcd” in their mobile authenticator, push notification, or security app.
That message is supposed to give the user confidence that everything looks right. But what if the attacker controls part of what the user sees?
Imagine this. I initiate a withdrawal to my own wallet address, something ugly like:
0xDeadBeefCafe0000000000000000000000000000
But I include a fake label in the request, like:
"label": "0xAbC123...4567 (Ledger Wallet)"
If the backend doesn’t sanitize or validate that label before sending the 2FA challenge, the user might see:
Approve withdrawal
Amount: 10 ETH
To: 0xAbC123...4567 (Ledger Wallet)
It looks legit, but it’s completely fake. The label is attacker-controlled. The actual funds are going to a malicious address.
This trick works because users trust what they see in security prompts. If the backend allows user-supplied metadata to appear in that prompt, it becomes a perfect tool for deception.
To test this, try creating a withdrawal with custom fields like label
, note
, or display_name
. If they show up in the 2FA app or prompt, you’ve found a dangerous vector for misleading users.
If the 2FA UI is showing attacker-supplied data, then it's no longer a second factor of authentication. It's a second factor of illusion.
Wrapping Up
Deposit and withdrawal flows might seem simple, but when Web3 meets Web2, things can get messy fast.
All the classic logic bugs from traditional apps show up here too race conditions, IDORs, weak 2FA enforcement, and misleading UI. The only difference is that now the bugs are moving money.
If you're testing one of these systems, forget the hype for a second and focus on the logic.
Ask yourself:
- Can I trigger the same action twice?
- Is the backend relying too much on the frontend?
- Are users being shown information they shouldn’t trust?
These flows are high-value targets, and small mistakes can lead to real losses.
Hopefully, this gave you a few new ideas to try out in your next project or audit.
Thanks for reading and see you next time. Keep poking at the logic and stay safe out there.
Chapters

Previous Issue
Next Issue
