All incidents

CEXISWAP Proxy Takeover

Share
Sep 21, 2023 06:56 UTCAttackLoss: 30,000 USDTPending manual check1 exploit txWindow: Atomic

Root Cause Analysis

CEXISWAP Proxy Takeover

1. Incident Overview TL;DR

CEXISWAP proxy 0xb8a5890d53df78dee6182a6c0968696e827e3305 was drained in Ethereum block 18182606 by transaction 0xede72a74d8398875b42d92c550539d72c830d3c3271a7641ee1843dc105de59e. The attacker first re-initialized the proxy with attacker-controlled role addresses, then used the newly granted upgrade role to call upgradeToAndCall, install attacker-controlled logic, and delegatecall a drain routine that transferred the proxy's full 30000000000 raw USDT balance to the attacker EOA.

The root cause was a missing one-time initializer guard. The proxy's initialize(string,string,address,address,address,address) entrypoint remained callable after prior successful initialization, so arbitrary callers could overwrite the role-bearing addresses and unlock the UUPS upgrade path.

2. Key Background

The victim contract is an upgradeable proxy-like system whose runtime dispatcher exposes three relevant selectors:

0xe56f2fe4 -> initialize(string,string,address,address,address,address)
0x4f1ef286 -> upgradeToAndCall(address,bytes)
0x3659cfe6 -> upgradeTo(address)

The validator-reviewed bytecode disassembly shows that initialize does not just set cosmetic metadata. It writes attacker-supplied addresses into configuration slots and grants three AccessControl-style role hashes. The same disassembly also shows that upgradeToAndCall is gated by one of those granted roles. Because the proxy still held 30000 USDT before block 18182606, a successful re-initialization immediately converted into a fund-draining upgrade primitive.

The attacker prepared a helper contract at 0x8c425ee62d18b65cc975767c27c42de548d133a1 in deployment tx 0x15810e9be29d65693b0a69f66d156ecce32ea4ea03f05aca67e98b6fbc77f995 at block 18182600. Its bytecode embeds the selectors for exploit(address), exploit2(), and upgradeTo(address), along with the USDT address and the EIP-1967 implementation slot constant.

3. Vulnerability Analysis & Root Cause Summary

This was an upgradeable-contract takeover caused by reusable initialization. The relevant safety invariant is that initialization must be one-time and permanently consumed after the first successful setup. CEXISWAP violated that invariant because the initialize dispatcher path remained externally reachable and directly rewrote privileged storage without checking whether initialization had already occurred. The disassembly summary places the initialize body at offset 0x1602, where attacker-supplied arguments are written into slots 0xfb, 0xfc, 0xfe, and 0xff, followed by role-admin setup and role grants. The upgrade path, reached from selector 0x4f1ef286, calls helper routine 0x1b8b, which checks hasRole(0x88aa7196...101508, CALLER) before continuing. Because initialize grants that exact role to the supplied address, re-initialization directly unlocks upgradeToAndCall for an arbitrary caller-controlled helper.

4. Detailed Root Cause Analysis

The seed trace shows the realized exploit path in one transaction:

0xB8a5890D53dF78dEE6182A6C0968696e827E3305::initialize("HAX", "HAX", helper, helper, helper, helper)
emit RoleGranted(...)
emit RoleGranted(...)
emit RoleGranted(...)
0xB8a5890D53dF78dEE6182A6C0968696e827E3305::upgradeToAndCall(0x8C425Ee62D18b65Cc975767C27c42dE548D133A1, 0x1de24bbf)
0x8C425Ee62D18b65Cc975767C27c42dE548D133A1::exploit2() [delegatecall]
TetherToken::transfer(0x060C169C4517D52c4BE9a1Dd53e41a3328d16F04, 30000000000)

The bytecode evidence explains why that trace was possible. In the victim runtime, selector 0xe56f2fe4 routes to the initialize body at 0x1602. That body performs direct state writes into the role-bearing/configuration slots and then executes role-linking and role-grant helper routines:

00001605: PUSH1 0xfb
0000160f: PUSH2 0x27e7   ; write arg into slot 0xfb
00001617: PUSH1 0xfc
00001621: PUSH2 0x27e7   ; write arg into slot 0xfc
00001666: SSTORE         ; write address into slot 0xfe
000016a7: SSTORE         ; write address into slot 0xff
00001700: PUSH32 0xa4980720...c21775
0000172a: PUSH32 0x5d8e12c3...a108ec
00001774: PUSH32 0x88aa7196...101508
000017e0: PUSH2 0x1ebb   ; grant role to supplied address

No consumed-initializer branch appears before those writes and grants. The upgrade path separately proves the privilege linkage:

00001b8f: PUSH32 0x88aa7196...101508
00001bb0: CALLER
00001bb1: PUSH2 0x0e5a   ; hasRole(role, caller)
00001bb9: JUMPI
00001bbd: REVERT

The exploit therefore required only three public-state conditions: the proxy still exposed reusable initialize, the proxy still held transferable assets, and the re-assigned role set was sufficient to satisfy the role check on upgradeToAndCall. Once those conditions held, the attacker could supply its own helper address during initialization, pass the role gate, upgrade the implementation to attacker code, and execute exploit2() in the proxy context. The seed trace then shows the entire 30000000000 raw USDT balance leaving the proxy. The native balance diff separately shows only attacker gas spend, which is consistent with a direct token drain rather than any hidden compensating outflow.

5. Adversary Flow Analysis

The adversary flow was deterministic and contract-assisted.

  1. In block 18182600, EOA 0x060c169c4517d52c4be9a1dd53e41a3328d16f04 deployed helper contract 0x8c425ee62d18b65cc975767c27c42de548d133a1 in tx 0x15810e9be29d65693b0a69f66d156ecce32ea4ea03f05aca67e98b6fbc77f995.
  2. In block 18182606, the same EOA invoked the helper through tx 0xede72a74d8398875b42d92c550539d72c830d3c3271a7641ee1843dc105de59e.
  3. The helper called initialize("HAX","HAX", helper, helper, helper, helper) on the victim proxy, causing the proxy to overwrite privileged slots and grant the helper the role hash later checked on the upgrade path.
  4. The helper immediately called upgradeToAndCall(helper, exploit2()), satisfying the role check because the prior re-initialization had already granted the required role.
  5. The proxy delegatecalled into exploit2(), which read USDT.balanceOf(address(this)) in proxy context and transferred the full balance to the attacker EOA.

This sequence is an ACT opportunity because it depends only on public bytecode, public chain state, and a permissionless transaction path. No privileged key, whitelist, or victim cooperation was required.

6. Impact & Losses

The direct observed loss was the proxy's full USDT balance at the exploit block:

[
  {
    "token_symbol": "USDT",
    "amount": "30000000000",
    "decimal": 6
  }
]

30000000000 raw units with 6 decimals equals 30000 USDT. The attacker also seized upgrade control over the proxy implementation during the exploit path, which means the bug was not just an accounting issue; it was a full privilege takeover of a contract that held user assets.

7. References

  • Seed exploit tx metadata: 0xede72a74d8398875b42d92c550539d72c830d3c3271a7641ee1843dc105de59e
  • Seed exploit trace showing initialize, upgradeToAndCall, delegatecall, and TetherToken::transfer
  • Seed balance diff confirming attacker gas spend and no contradictory native-value movement
  • Auditor bytecode evidence summary for the victim proxy and attacker helper
  • Victim proxy address: 0xb8a5890d53df78dee6182a6c0968696e827e3305
  • Attacker helper deployment tx: 0x15810e9be29d65693b0a69f66d156ecce32ea4ea03f05aca67e98b6fbc77f995