All incidents

MetaPoint Public Approval Drain

Share
Apr 11, 2023 20:32 UTCAttackLoss: 8,961.18 POTPending manual check2 exploit txWindow: 0s
Estimated Impact
8,961.18 POT
Label
Attack
Exploit Tx
2
Addresses
1
Attack Window
0s
Apr 11, 2023 20:32 UTC → Apr 11, 2023 20:32 UTC

Exploit Transactions

TX 1BSC
0xdb01fa33bf5b79a3976ed149913ba0a18ddd444a072a2f34a0042bf32e4e7995
Apr 11, 2023 20:32 UTCExplorer
TX 2BSC
0x41853747231dcf01017cf419e6e4aa86757e59479964bafdce0921d3e616cc67
Apr 11, 2023 20:32 UTCExplorer

Victim Addresses

0x71a0c153f52cefcf6d26253f67b10dabed5556ffBSC

Loss Breakdown

8,961.18POT

Similar Incidents

Root Cause Analysis

MetaPoint Public Approval Drain

1. Incident Overview TL;DR

The MetaPoint incident was an anyone-can-take drain, not a private attacker-only siphon. At the incident block, proxy 0x71a0c153f52cefcf6d26253f67b10dabed5556ff pointed to implementation 0x2d9e76e8ba6a52bd3c01f8eefbc15b149eb7cb0d, which routed each depositor's POT into a bespoke wallet contract. The critical bug was inside that wallet runtime: its only public function blindly executed MetaPoint.approve(msg.sender, type(uint256).max). Once a victim deposit sat in one of those wallets, any unprivileged actor can call approve() and then use MetaPoint.transferFrom to drain the wallet.

2. Key Background

MetaPoint exposed deposits through proxy 0x71a0c153f52cefcf6d26253f67b10dabed5556ff. Independent incident-block checks show its EIP-1967 admin was 0x6b5d5fd3d8d676b0e059231cf39e70a71a4d2723 and its implementation at blocks 27255109 and 27264383 was 0x2d9e76e8ba6a52bd3c01f8eefbc15b149eb7cb0d. Storage slot 0x97 held MetaPoint token 0x3b5e381130673f794a5cf67fbba48688386bea86, and per-user deposit wallets were stored in mapping slot 0x98.

The representative victim path is concrete. For victim EOA 0xcbbe1aed59117b467de525a61fefd0676da72d52, keccak256(victim, 0x98) resolves to slot 0x1cf11666f4ed0b3a7572263f1206fd30b920662e248221f65a3385ba9cbac7b7, and that slot stored wallet 0xe5cbd18db5c1930c0a07696ec908f20626a55e3c at the deposit block. Other sampled mappings show 0xf54aa93b4ee521833326329eb9de53d33015a1db -> 0xc254741776a13f0c3eff755a740a4b2aae14a136 and 0x07d17ab270c105d9822011d1bc4275522013eb92 -> 0x5923375f1a732fd919d320800eaecc25910beda3. Receipt 0xcfa73dc692d3eb46818929737749cc429be0204f6febe27842fa3c3803e0b66a shows the victim's 913 POT moving directly into that wallet:

Source: representative victim deposit receipt

MetaPoint Transfer(
  from = 0xcbbe1aed59117b467de525a61fefd0676da72d52,
  to   = 0xe5cbd18db5c1930c0a07696ec908f20626a55e3c,
  value= 913000000000000000000
)
DepositProxy Deposit(
  user      = 0xcbbe1aed59117b467de525a61fefd0676da72d52,
  amount    = 913000000000000000000,
  timestamp = 0x643555a7
)

3. Vulnerability Analysis & Root Cause Summary

The incident-time implementation did not escrow deposits in a controlled vault. Instead, deposit(uint256) derived a per-user slot from keccak256(caller, 0x98), created a bespoke wallet when necessary, and then transferred POT from the depositor into that wallet. That design would only be safe if the wallet enforced ownership over outbound approvals or transfers. It did not. The wallet runtime exposed a single selector, 0x12424e3f, and that selector constructed approve(msg.sender, type(uint256).max) against the MetaPoint token with no owner check, whitelist, or role gate. The observed helper 0xc6e451c8fa3418b703121ac23e44803d143c5d5c was therefore incidental attacker infrastructure: it batch-called the same broken wallet function, but the protocol-side invariant break was already permissionless.

4. Detailed Root Cause Analysis

The incident-time implementation disassembly shows the deposit path computing the per-user wallet slot, creating the wallet when the slot is empty, and then routing token custody into that wallet:

Source: incident-time implementation disassembly

000016d2: CALLER
000016d8: PUSH1 0x98
000016e0: KECCAK256
000016e2: SLOAD
...
00001702: CALLER
00001703: PUSH3 0x0027ac
...
00001770: PUSH1 0x97
...
000021a8: PUSH32 0x23b872dd00000000000000000000000000000000000000000000000000000000

Those instructions map to the concrete behavior observed on-chain:

  1. CALLER plus slot 0x98 derives a per-user mapping entry for the depositor.
  2. Routine 0x27ac executes CREATE, proving the implementation deploys a bespoke wallet for the depositor instead of using a shared vault.
  3. Routine 0x2141 builds selector 0x23b872dd, which is ERC20 transferFrom(address,address,uint256).
  4. The receipt for tx 0xcfa73dc692d3eb46818929737749cc429be0204f6febe27842fa3c3803e0b66a confirms the practical effect: the victim's POT lands in wallet 0xe5cbd1....

The wallet runtime itself is the code-level breakpoint. It exposes exactly one public function and hardcodes both the approval selector and the MetaPoint token:

Source: representative wallet runtime disassembly

00000021: PUSH4 0x12424e3f
...
00000041: PUSH32 0x095ea7b300000000000000000000000000000000000000000000000000000000
00000061: CALLER
...
00000067: PUSH32 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
...
0000008d: PUSH32 0x0000000000000000000000003b5e381130673f794a5cf67fbba48688386bea86

That sequence is unambiguous: wallet selector 0x12424e3f executes MetaPoint.approve(msg.sender, type(uint256).max). Because the spender is CALLER, not a stored owner or privileged operator, any searcher can self-authorize against the wallet and then drain it with public ERC20 transferFrom.

The original attacker helper proves this was the exact path used in production, while also showing that the helper gate was not protocol-side protection:

Source: observed helper disassembly

000000c3: CALLER
000000c4: PUSH20 0x0d1969a30bb4ba02e731862edbced5f5abba8373
000000d9: EQ
...
000001ca: PUSH4 0x12424e3f

The helper required caller 0x0d1969..., but then simply called the wallet's public approve() entrypoint. Since the wallet already approved any caller, the helper's private gate did not prevent other actors from exploiting the same protocol bug directly.

5. Adversary Flow Analysis

The observed seeded realization used two transactions in block 27264384 against MetaPoint/USDT pair 0x9117df9aa33b23c0a9c2c913ad0739273c3930b3 and USDT/WBNB pair 0x16b9a82891338f9ba80e2d6970fdda79d1eb0dae:

  1. Tx 0xdb01fa33bf5b79a3976ed149913ba0a18ddd444a072a2f34a0042bf32e4e7995 batch-called wallet approve() through helper 0xc6e451....
  2. Tx 0x41853747231dcf01017cf419e6e4aa86757e59479964bafdce0921d3e616cc67 used the resulting allowances to sell wallet balances into the MetaPoint/USDT pair and then into WBNB.

Source: seed traces

0xc6E451...::3cc17c29(...)
  0xE5cBd18D...::approve()
    MetaPoint::approve(0xc6E451..., type(uint256).max)

0xc6E451...::319b79c5(...)
  MetaPoint::transferFrom(0xE5cBd18D..., 0x9117df9a..., 913000000000000000000)
  ...
  WBNB::transfer(0x0D1969A30bB4BA02e731862edBCed5F5abBa8373, 83861198236332884651)

The minimal ACT path is shorter than the observed attack:

  1. Discover a funded custody wallet from public storage or prior deposit logs.
  2. Call the wallet's public approve() function from a fresh unprivileged address.
  3. Call MetaPoint.transferFrom(wallet, attacker, walletBalance) and receive the victim's POT directly.

The auditor's fork reproduction of wallet 0xe5cbd1... at block 27264383 confirmed exactly that minimal path: a fresh address drained the full 913 POT balance without using the original helper or attacker EOA.

6. Impact & Losses

The observed seeded realization sold 8961180541666600000000 raw POT units, or 8961.1805416666 POT, across ten funded deposit wallets. Balance-diff and trace evidence also show 448.05902708333 POT diverted to fee address 0x9594168728483a0bca264d42e7db3f97f4510577, 27392557081151330526721 USDT routed through the second pair, and 83861198236332884651 WBNB sent to 0x0d1969.... Independent balance checks confirm the seed sender's combined native BNB plus WBNB position increased from 1888830648685175307423 wei-equivalent to 1972687678831508192074 wei-equivalent, a net gain of 83857030146332884651 wei after gas.

The broader impact is larger than the observed drain because the vulnerability is permissionless. Any MetaPoint deposit already parked in one of these bespoke wallets was public loot until moved elsewhere.

7. References

  • Deposit proxy: 0x71a0c153f52cefcf6d26253f67b10dabed5556ff
  • Incident-time implementation: 0x2d9e76e8ba6a52bd3c01f8eefbc15b149eb7cb0d
  • MetaPoint token: 0x3b5e381130673f794a5cf67fbba48688386bea86
  • Representative victim wallet: 0xe5cbd18db5c1930c0a07696ec908f20626a55e3c
  • Original helper: 0xc6e451c8fa3418b703121ac23e44803d143c5d5c
  • MetaPoint/USDT pair: 0x9117df9aa33b23c0a9c2c913ad0739273c3930b3
  • USDT/WBNB pair: 0x16b9a82891338f9ba80e2d6970fdda79d1eb0dae
  • Representative victim deposit tx: 0xcfa73dc692d3eb46818929737749cc429be0204f6febe27842fa3c3803e0b66a
  • Second sampled victim deposit tx: 0xc46e9af5041dca053015c21dc8b717bee2d5b29a8ca0e794c9fa739f34452446
  • Seed approval tx: 0xdb01fa33bf5b79a3976ed149913ba0a18ddd444a072a2f34a0042bf32e4e7995
  • Seed liquidation tx: 0x41853747231dcf01017cf419e6e4aa86757e59479964bafdce0921d3e616cc67
  • Supporting artifacts: incident-time implementation disassembly, representative wallet disassembly, helper disassembly, seed traces, and balance diffs in the session artifacts