Dx Protocol: Repeated Withdrawals via unlockToken() Timing Bug

BSC • Address: 0xeb3a…e449

Published on May 28, 2025

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

Impact

Roughly $5.2M in value sat behind this contract while it was still unverified. Without decompiling, that risk would have stayed invisible.

Fix

Reproduce

  1. Drop the address into EVMDecompiler.
  2. Skim to unlockToken() in the reconstructed code.
  3. Watch how isLocked only changes inside the block.timestamp branch 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.