Critical Severity Bug Disclosure: Oasys
A missing ownership check in an ERC-721 bridge allowing anyone to steal another user's tokens.
A few weeks ago I found a critical severity bug in the Oasys bridge contracts. It was confirmed by their team via Immunefi and a bounty was paid.
Below is a detailed write-up explaining how a missing authorization check was allowing direct theft of tokens using the bridge.
Background: Oasys, Hub Layer, Verse Layer, and bridged NFTs
Oasys is a gaming-focused blockchain ecosystem built around a two-layer architecture: the Hub Layer and Verse Layers. The Hub Layer acts as the base layer for shared infrastructure such as token management and bridge-related state, while Verse Layers are the application-specific environments where games and other user-facing products run.
The bridge flow
Looking at the Verse-layer side bridge, the path to bridge an asset to the Hub-layer is via the withdraw function on the L2ERC721Bridge contract.
The user specifies the token to bridge _l2Token, the _tokenId. ()
_l1Gas is minimum gas supplied for the remote finalization call _data is arbitrary data passed through the bridge message.
function withdraw( address _l2Token, uint256 _tokenId, uint32 _l1Gas, bytes calldata _data ) external virtual { _initiateWithdrawal(_l2Token, msg.sender, msg.sender, _tokenId, _l1Gas, _data); }Then the internal logic does the following:
- burn the token through
IL2StandardERC721(_l2Token).burn(msg.sender, _tokenId); - craft the message that will be sent to the Hub-layer side (via
finalizeERC721Withdrawal) - sends the message through the cross-domain messenger.
This is a typical burn-and-mint architecture.
function _initiateWithdrawal( address _l2Token, address _from, address _to, uint256 _tokenId, uint32 _l1Gas, bytes calldata _data ) internal { // When a withdrawal is initiated, we burn the withdrawer's funds to prevent subsequent L2 // usage // slither-disable-next-line reentrancy-events IL2StandardERC721(_l2Token).burn(msg.sender, _tokenId);
// Construct calldata for l1TokenBridge.finalizeERC20Withdrawal(_to, _tokenId) // slither-disable-next-line reentrancy-events address l1Token = IL2StandardERC721(_l2Token).l1Token(); bytes memory message;
message = abi.encodeWithSelector( IL1ERC721Bridge.finalizeERC721Withdrawal.selector, l1Token, _l2Token, _from, _to, _tokenId, _data );
// Send message up to L1 bridge // slither-disable-next-line reentrancy-events sendCrossDomainMessage(l1ERC721Bridge, _l1Gas, message);
// slither-disable-next-line reentrancy-events emit WithdrawalInitiated(l1Token, _l2Token, msg.sender, _to, _tokenId, _data); }If this architecture looks familiar, that’s because it is based on the Optimism L2StandardBridge, which handles ERC20 and native tokens.
Let us now have a look at the burn logic in the L2StandardERC721 contract
function burn(address _from, uint256 _tokenId) public virtual onlyL2Bridge { _burn(_tokenId);
emit L2BridgeBurn(_from, _tokenId);}Something looks off.
The vulnerability
There is access control (onlyL2Bridge) ensuring this function can only be called by the bridge.
But no verification on _from.
function burn(address _from, uint256 _tokenId) public virtual onlyL2Bridge { _burn(_tokenId); //@audit no ownership check on `_from`?
emit L2BridgeBurn(_from, _tokenId);}At a glance, this can look safe because the bridge passes msg.sender into burn().
IL2StandardERC721(_l2Token).burn(msg.sender, _tokenId);But the problem is that in burn, nothing ties _from to the ownership of _tokenId.
And crucially, OpenZeppelin’s internal ERC-721 _burn() is not an authorization function. It assumes the caller already performed access control.
function _burn(uint256 tokenId) internal virtual { address owner = ERC721.ownerOf(tokenId);
_beforeTokenTransfer(owner, address(0), tokenId);
owner = ERC721.ownerOf(tokenId);
delete _tokenApprovals[tokenId];
unchecked { _balances[owner] -= 1; }
delete _owners[tokenId];
emit Transfer(owner, address(0), tokenId);}Note the difference with the Optimism L2StandardBridge token implementation, OptimismMintableERC20,
where burn decreases the balance of _from
function burn(address _from, uint256 _amount) external virtual onlyBridge { _burn(_from, _amount); emit Burn(_from, _amount);}
...
// @openzeppelin/contracts/token/ERC20/ERC20.sol
function _burn(address account, uint256 amount) internal { require(account != address(0), "ERC20: burn from the zero address"); _update(account, address(0), amount);}How it can be exploited
So the bridge calls burn() on the token, but does not check if the caller is authorized (token owner or approved).
This means anyone can call withdraw on any tokenId, on any _l2Token, burning that token and minting the counterpart on the Hub chain:
- Bob owns
tokenId = 2of a tokenl2Tokenon the Verse Layer. - Alice calls
withdraw, passingl2TokenandtokenId = 2 - The bridge calls
burn(Alice, 2). - The token contract ignores the fact Alice does not own
2and calls_burn(2). _burn(2)deletes Bob’s ownership record.- The bridge emits and sends a withdrawal message that finalizes the exit for Alice.
Alice walks away with that token on the Hub layer, and Bob has lost his token on the Verse layer.
The impact
This affects NFTs bridged through Oasys’s standard ERC-721 bridge, which is the common path for game assets moving between the Hub and Verse layers. Any of these items could be swept away by another party. Imagine logging into your game and seeing all your character’s equipment is gone.
The fix
There are two options to handle the token authorization:
1 - a strict owner-only fix in the burn function of the token L2ERC721Bridge implementation.
function burn(address _from, uint256 _tokenId) public virtual onlyL2Bridge { require(ownerOf(_tokenId) == _from, "Not owner of the token");
_burn(_tokenId);
emit L2BridgeBurn(_from, _tokenId);}This is the fix Oasys implemented
2 - Another option would have been to also allow approved operators, closer to the ERC-721 semantics.
address owner = ownerOf(_tokenId);
require( msg.sender == owner || getApproved(_tokenId) == msg.sender || isApprovedForAll(owner, msg.sender), "unauthorized caller");Takeaway
This is a trust boundary issue between two contracts:
- The bridge relies on the token contract to burn the NFT.
- The token contract relies on the bridge to be the only caller.
But none of them checks if the caller is allowed to handle the token.
Both contracts had correct standalone logic, but put together they introduced an authorization gap.
This is a good reminder that you should always be mindful of the modular approach and verify a system as a whole, not just the standalone components.
Eraikey Labs is a blockchain security firm founded Joe Stakey, a consistently top-ranked whitehat on Immunefi since 2023. We provide security services throughout the entire development lifecycle. Get in touch.