Abstract
A community researcher asked us to take a look at Dx Protocol's locker on BSC. Within a few minutes of decompiling, the timing flaw in unlockToken() jumped out — callers could keep yanking funds even though the contract insisted the position was still locked. It's the kind of bug that hides in bytecode until someone takes the time to read it.
We pulled down the unverified Dx Protocol contract on BSC in EVMDecompiler and walked through the reconstructed code line by line. The logic snag in unlockToken() was impossible to miss once the control flow was human-readable.
What went wrong
Calling unlockToken() before unlockTime still triggers the transfer, but it never flips isLocked to false. The contract insists the lock is intact while happily sending funds, so attackers can loop the call and drain the balance.
Decompiled fragment
function unlockToken(uint256 _tokenId) public {
require(tokenLocks[msg.sender][_tokenId].isLocked, "Token is already unlocked");
require(tokenLocks[msg.sender][_tokenId].unlockTime != 0, "Token is not locked");
if (block.timestamp > tokenLocks[msg.sender][_tokenId].unlockTime) {
tokenLocks[msg.sender][_tokenId].isLocked = false;
}
uint256 amount = tokenLocks[msg.sender][_tokenId].amount;
require(IERC20(tokenLocks[msg.sender][_tokenId].tokenAddress).balanceOf(address(this)) >= amount, "Not enough balance");
require(IERC20(tokenLocks[msg.sender][_tokenId].tokenAddress).transfer(msg.sender, amount), "Failed transfer");
emit Unlocked(msg.sender, tokenLocks[msg.sender][_tokenId].tokenAddress, amount, block.timestamp);
}Why it slipped through
isLockedonly flips tofalsewhenblock.timestamp > unlockTime.- Before then, state stays locked while the transfer still fires, which is the perfect setup for repeated withdrawals.
Impact
Roughly $5.2M in value sat behind this contract while it was still unverified. Without decompiling, that risk would have stayed invisible.
Fix
- Require
block.timestamp >= unlockTimebefore letting the transfer proceed. - Flip
isLocked = false(and maybe zero outunlockTime) before sending funds so the state matches reality.
Reproduce
- Drop the address into EVMDecompiler.
- Skim to
unlockToken()in the reconstructed code. - Watch how
isLockedonly changes inside theblock.timestampbranch while the transfer runs every time.
Disclosure note: We share findings like this to support responsible security work and keep the ecosystem a little safer.