On Base at 2026-04-29 02:22:41 UTC, the Syndicate Commons bridge proxy at 0x64d426e64b7191a1fcf48c89d2d12772fbfb297f was drained via a privileged upgrade compromise. The loss in the analyzed transaction was 18,445,374.987536122947149447 SYND (about 18.445M SYND, reported publicly as roughly $330k). This transaction was the final drain step, not the initial compromise: control of the bridge’s upgrade path had already been seized in earlier transactions.
Incident Summary
This incident is best understood as a three-stage attack rather than as a bug in a normal bridge function.
First, an attacker-controlled path gained control over the bridge’s upgrade admin. Next, the attacker used that new authority to replace the legitimate bridge implementation with a malicious implementation. Finally, the attacker called the newly installed drain entrypoint, which caused the proxy to transfer its entire SYND balance to the attacker EOA.
The key point is that the last drain transaction only worked because the bridge proxy had already been repointed to attacker-controlled logic. The original bridge flow was no longer what the proxy was executing at exploit time.
Attack Narrative
Stage 1 - Seize Upgrade Control
The control-plane compromise happened in transaction 0xd92ba704c491ff0a085558d965fc078b66dbaf9ad1c8ce3588b4a8c5e37e0b1a at block 45319998.
The caller 0x61e667cb3128ac161cd68033639dfbb496e30950 sent a transaction to UpgradeExecutor proxy 0xc924178de522feb6147df2668e44fe7683852cee using selector 0xbca8c7b5, which resolves to executeCall(address,bytes). The calldata instructed UpgradeExecutor to call transferOwnership(address) on ProxyAdmin 0x7b0ab8f15940f573c1d82c2238cfa8368edbf9c6, setting the new owner to attacker EOA 0x58e2e08357992bc4a4a02f22321c3e0f6b01893e.
This transaction succeeded with status 0x1 and emitted an OwnershipTransferred(address,address) event on ProxyAdmin, proving that ownership moved from 0xc924178de522feb6147df2668e44fe7683852cee to 0x58e2e08357992bc4a4a02f22321c3e0f6b01893e.
Operationally, this was the decisive step: once the attacker controlled ProxyAdmin, they controlled which implementation the bridge proxy would execute.
Why Stage 1 Was Possible
This stage was not the attacker directly calling ProxyAdmin as its owner. Instead, the attacker path used an address that already had permission to invoke UpgradeExecutor.executeCall(address,bytes), and UpgradeExecutor itself was the owner of ProxyAdmin immediately before the takeover.
The on-chain logic therefore worked as follows:
0x61e667...calledUpgradeExecutor.executeCall(target, data).UpgradeExecutoraccepted that call because the caller hadEXECUTOR_ROLE.UpgradeExecutorthen performed an external call toProxyAdmin.ProxyAdminsawmsg.sender == 0xc924..., which was its legitimate owner at that time.- As a result,
transferOwnership(0x58e2...)succeeded and handed upgrade control to the attacker.
This is the critical architectural point: the address that submitted the transaction did not need to be the owner of ProxyAdmin. It only needed to be able to drive UpgradeExecutor, because UpgradeExecutor was already sitting in the owner position over ProxyAdmin.
What the Contract Problem Appears To Be
From the chain evidence alone, this first stage does not look like a classic Solidity bug such as a missing onlyOwner, reentrancy, or arithmetic flaw. Instead, it looks like a high-privilege execution path being too powerful.
UpgradeExecutor.executeCall(address,bytes) is guarded by onlyRole(EXECUTOR_ROLE), but once that check passes it can make an arbitrary call to an arbitrary target with arbitrary calldata. In this deployment, that mattered because ProxyAdmin ownership was held by UpgradeExecutor. The result is that compromise or misuse of an EXECUTOR_ROLE address effectively becomes compromise of the upgrade admin itself.
In practical terms, the dangerous combination was:
EXECUTOR_ROLEcould trigger unrestrictedexecuteCallProxyAdmin.owner()wasUpgradeExecutorProxyAdmincontrols which implementation the bridge proxy executes
That means the executor role was not just an operational helper role. It was, indirectly, a path to full upgrade authority over the bridge.
Stage 2 - Replace the Bridge Logic
Four blocks later, in transaction 0x48b36cd9d50348e2fb0082e9af6abebf1c4c9ef1d268923ce5ef79c4dc613178 at block 45320003, attacker EOA 0x58e2e08357992bc4a4a02f22321c3e0f6b01893e called ProxyAdmin 0x7b0ab8f15940f573c1d82c2238cfa8368edbf9c6 with selector 0x99a88ec4, which resolves to upgrade(address,address).
That call upgraded CommonsBridgeProxy 0x64d426e64b7191a1fcf48c89d2d12772fbfb297f to implementation 0xd5e2237968e36a1e6ec11bdea8e04bcf821247f8. The transaction succeeded and the proxy emitted Upgraded(address), confirming that the new implementation was active.
From this point onward, calls to the bridge proxy no longer executed the previous bridge implementation. They executed the attacker’s implementation instead.
Stage 3 - Drain the SYND Balance
The actual drain happened in transaction 0xb4aca0c74ec2526631771c9f2d1936e8ba12264d41b2ed188f145c52bcd475dc at block 45320007.
In this transaction, attacker EOA 0x58e2e08357992bc4a4a02f22321c3e0f6b01893e called the bridge proxy 0x64d426e64b7191a1fcf48c89d2d12772fbfb297f with a 4-byte selector only: 0x5b8ec19f.
The proxy forwarded the call to implementation 0xd5e2237968e36a1e6ec11bdea8e04bcf821247f8 via DELEGATECALL. Because this was a delegatecall, the implementation ran in the storage and balance context of the proxy. In practice, that means address(this) inside the malicious implementation referred to the bridge proxy, which was the account actually holding the bridged SYND balance.
The recovered implementation behavior and the trace align on three concrete steps:
- It only allowed calls from attacker EOA
0x58e2.... - It queried
SYND.balanceOf(address(this)), whereaddress(this)was the proxy. - It transferred that entire balance to
0x58e2....
So the drain was not a partial withdrawal and did not depend on a forged bridge message in this final transaction. It was a direct balance sweep after the proxy had already been turned into attacker-controlled code.
Why the Final Transaction Works
The final drain transaction only makes sense together with the earlier admin-takeover and upgrade transactions.
If the attacker had called selector 0x5b8ec19f against the original bridge implementation, the call would not have produced this balance sweep. The reason it succeeded is that the proxy had already been upgraded to malicious logic. In other words, the exploitable event was the loss of upgrade control; the drain selector was simply the attacker’s final cash-out step.
Detailed Call Trace
The following call flow is derived directly from trace_callTracer.json for transaction 0xb4aca0c74ec2526631771c9f2d1936e8ba12264d41b2ed188f145c52bcd475dc:
0x58e2e08357992bc4a4a02f22321c3e0f6b01893e->0x64d426e64b7191a1fcf48c89d2d12772fbfb297fCALL- selector
0x5b8ec19f - no ETH value
0x64d426e64b7191a1fcf48c89d2d12772fbfb297f->0xd5e2237968e36a1e6ec11bdea8e04bcf821247f8DELEGATECALL- selector
0x5b8ec19f - execution context remains the proxy, so
address(this)is still0x64d4...
0x64d426e64b7191a1fcf48c89d2d12772fbfb297f->0x11dc28d01984079b7efe7763b533e6ed9e3722b9STATICCALLbalanceOf(address)/ selector0x70a08231- argument:
0x64d426e64b7191a1fcf48c89d2d12772fbfb297f - return value:
0x0000000000000000000000000000000000000000000f41f5c81cd2fefd599e87
0x64d426e64b7191a1fcf48c89d2d12772fbfb297f->0x11dc28d01984079b7efe7763b533e6ed9e3722b9CALLtransfer(address,uint256)/ selector0xa9059cbb- recipient:
0x58e2e08357992bc4a4a02f22321c3e0f6b01893e - amount:
0x0f41f5c81cd2fefd599e87=18,445,374.987536122947149447 SYND
Financial Impact
The bridge proxy lost 18,445,374.987536122947149447 SYND, and the attacker EOA gained the exact same amount in the same transaction according to funds_flow.json. The sole token transfer in the receipt moved the entire SYND balance from 0x64d426e64b7191a1fcf48c89d2d12772fbfb297f to 0x58e2e08357992bc4a4a02f22321c3e0f6b01893e, so the proxy’s custodial SYND inventory was fully drained in this step. Public incident reporting and the follow-on liquidation activity cited in the incident brief put the realized value at roughly $330k.
The direct losers were the funds custodied by the Commons bridge proxy, which functionally means the bridge treasury or bridged-asset pool rather than a single per-user balance in this transaction. This report does not show other tokens being drained from the same proxy in the analyzed transaction, but the compromised upgrade path would have put any assets held by the proxy at risk until access was revoked.
Evidence
UpgradeExecutor.executeCall(address,bytes)exists atartifacts/analysis_0xb4aca0c74ec2526631771c9f2d1936e8ba12264d41b2ed188f145c52bcd475dc/0x1273bad06599b67722b4f94334e957d2c2d3a0f7/src/UpgradeExecutor.sol:73hasRole(EXECUTOR_ROLE, 0x61e667cb3128ac161cd68033639dfbb496e30950) == trueimmediately before the takeover transaction, confirming the caller was already an authorized executorProxyAdmin.owner()was0xc924178de522feb6147df2668e44fe7683852ceeimmediately before the takeover and0x58e2e08357992bc4a4a02f22321c3e0f6b01893eimmediately afterOwnershipTransferred(address,address)onProxyAdmin0x7b0ab8f15940f573c1d82c2238cfa8368edbf9c6in transaction0xd92ba704c491ff0a085558d965fc078b66dbaf9ad1c8ce3588b4a8c5e37e0b1amoved ownership from0xc924178de522feb6147df2668e44fe7683852ceeto0x58e2e08357992bc4a4a02f22321c3e0f6b01893eUpgraded(address)onCommonsBridgeProxy0x64d426e64b7191a1fcf48c89d2d12772fbfb297fin transaction0x48b36cd9d50348e2fb0082e9af6abebf1c4c9ef1d268923ce5ef79c4dc613178set implementation0xd5e2237968e36a1e6ec11bdea8e04bcf821247f8- The recovered malicious implementation only exposes the unresolved selector
0x5b8ec19fand implements the sweep behavior observed in the trace; seeartifacts/analysis_0xb4aca0c74ec2526631771c9f2d1936e8ba12264d41b2ed188f145c52bcd475dc/0xd5e2237968e36a1e6ec11bdea8e04bcf821247f8/recovered.sol:16 - The drain transaction receipt succeeded with status
0x1and emitted a single relevantTransferlog atlogIndex 232moving18,445,374.987536122947149447 SYNDfrom the proxy to the attacker - Selector checks:
0xbca8c7b5 = executeCall(address,bytes),0xf2fde38b = transferOwnership(address),0x99a88ec4 = upgrade(address,address),0x70a08231 = balanceOf(address),0xa9059cbb = transfer(address,uint256); selector0x5b8ec19fremains unresolved by public signature databases
Related URLs
- https://basescan.org/tx/0xb4aca0c74ec2526631771c9f2d1936e8ba12264d41b2ed188f145c52bcd475dc
- https://basescan.org/tx/0x48b36cd9d50348e2fb0082e9af6abebf1c4c9ef1d268923ce5ef79c4dc613178
- https://basescan.org/tx/0xd92ba704c491ff0a085558d965fc078b66dbaf9ad1c8ce3588b4a8c5e37e0b1a
- https://basescan.org/address/0x58e2e08357992bc4a4a02f22321c3e0f6b01893e
- https://x.com/syndicateio/status/2049352309784904187