We decompiled an unverified Dx Protocol contract on BSC using EVMDecompiler and uncovered a logic flaw in unlockToken()
.
The issue
If unlockToken()
is called before unlockTime
, the function still transfers tokens to the caller but leaves isLocked
set to true
. This allows the caller to repeatedly invoke unlockToken()
and withdraw again and again.
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 breaks
isLocked
is only flipped tofalse
whenblock.timestamp > unlockTime
.- When called earlier, the state remains locked but the transfer proceeds, enabling repeated withdrawals.
Impact
Estimated exposure was approximately $5.2M, hidden entirely in bytecode due to the contract being unverified at the time of analysis.
Fix
- Require
block.timestamp >= unlockTime
before any transfer logic. - Set
isLocked = false
(and/or zero outunlockTime
) before transferring tokens to close re-entry style repeated calls.
Reproduce
- Load the address in the EVMDecompiler.
- Locate
unlockToken()
in the decompiled output. - Observe the conditional write to
isLocked
versus the unconditional transfer path.
Disclosure note: This post is for educational purposes and responsible vulnerability awareness.