Reproduced Exploit

Tapioca twTAP — anyone can claim an approved position's rewards

twTAP.claimRewards checks that the recipient _to owns the position or is approved for it. It never checks msg.sender. A destination-chain permit that anyone can deliver approves TapTokenReceiver. The attacker then delivers the claim message, names themselves as sendTo, and the receiver forwards the…

Feb 2024•Other•access-control•4 min read

Chain

Other

Category

access-control

Date

Feb 2024

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 32314-h-03-absence-of-restrictions-on-the-sender-of-the-twtapclaim. Standalone Foundry PoC and full write-up: 32314-h-03-absence-of-restrictions-on-the-sender-of-the-twtapclaim_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/access-control/missing-auth · vuln/access-control/missing-owner-check · vuln/logic/missing-check · vuln/logic/reward-calculation

Key info#

ProtocolTapioca DAO
FindingCode4rena 2024-02 H-03, AuditVault 32314
Vulnerable codeTwTAP.claimRewards
Harm1,000 TAP taken by the attacker; the victim's later claim returns 0
Chainlocal synthetic

TL;DR#

twTAP.claimRewards checks that the recipient _to owns the position or is approved for it. It never checks msg.sender. A destination-chain permit that anyone can deliver approves TapTokenReceiver. The attacker then delivers the claim message, names themselves as sendTo, and the receiver forwards the position's TAP. The owner's own claim, run after that, returns 0.

The vulnerable code#

SOLIDITY
function claimRewards(uint256 _tokenId, address _to)
    external
    nonReentrant
    whenNotPaused
    returns (uint256[] memory amounts_)
{
    _requireClaimPermission(_to, _tokenId);
    amounts_ = _claimRewards(_tokenId, _to);
}

function _requireClaimPermission(address _to, uint256 _tokenId) internal view {
    // @> VULN: checks `_to`, not msg.sender. Any caller can claim to an approved receiver.
    if (!_isApprovedOrOwner(_to, _tokenId) && !isERC721Approved(_ownerOf(_tokenId), _to, address(this), _tokenId)) {
        revert NotApproved(_tokenId, msg.sender);
    }
}

The warden's patch also requires _requireClaimPermission(msg.sender, _tokenId). Tapioca noted that this still lets the receiver through, because in that call msg.sender == _to. Restricting the caller to the position owner closes both the direct call and the receiver path.

Root cause#

The permission helper asks whether _to is the owner or an approved operator. The cross-chain claim is built so _to is TapTokenReceiver, which the user is expected to approve. Nothing asks whether the account that triggered the permit, or the account that sent the claim message, is that owner. The receiver then pays sendTo from the message, and that field is attacker-controlled.

Preconditions#

The victim holds a twTAP position with accrued TAP. The destination-chain permit that approves TapTokenReceiver can be delivered by anyone, not only by the owner. The claim message is not bound to the source-chain sender.

Attack walkthrough#

  1. Position 1 is owed 1,000 TAP, escrowed in twTAP. The owner is the victim.
  2. The attacker delivers the permit. getApproved(1) becomes TapTokenReceiver.
  3. The attacker calls the claim message with sendTo set to themselves.
  4. The receiver calls claimRewards(1, address(this)). The permission check sees an approved _to and does not look at the caller.
  5. twTAP transfers 1,000 TAP to the receiver. The receiver forwards it to the attacker.
  6. The victim's later claim message runs the same path and gets 0. The victim's TAP balance stays 0.

The control requires msg.sender to be the position owner. The attacker's call reverts. The victim's call still pays the 1,000 TAP to the victim.

Diagrams#

flowchart TD A["Position 1 is owed 1000 TAP"] --> B["Attacker delivers the public permit"] B --> C["TapTokenReceiver is approved for the position"] C --> D["Attacker sends the claim message with sendTo set to self"] D --> E["VULN: claimRewards checks the recipient, not the caller"] E --> F["1000 TAP is paid to TapTokenReceiver"] F --> G["Receiver forwards 1000 TAP to the attacker"] G --> H["Victim claim now returns 0"]

Impact#

The attacker holds the 1,000 TAP that belonged to the victim's position. A later claim, including the owner's own cross-chain claim, returns an empty amount, so nothing is bridged to the victim. The report's front-run variant leaves the same tokens sitting in TapTokenReceiver instead of forwarding them; either way the owner is paid 0.

Remediation#

Only the position owner may call claimRewards. Checking that the caller is merely approved does not help when the approved address is TapTokenReceiver and the attacker is the one who asked it to claim.

How to reproduce#

BASH
cd 32314-h-03-absence-of-restrictions-on-the-sender-of-the-twtapclaim_exp
forge test -vv

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.