Reproduced Exploit
Symbiosis BridgeV2 — signed receive mints unbounded syBTC (BSC)
Symbiosis BridgeV2 treats an MPC signature as sufficient to mint synthetic BTC.
Loss
Headline ~$336k realized (4.39 WBTC dumped on Ethereum Uni V4). This PoC reproduces the BSC-side unbounded sy…
Chain
BNB Chain
Category
bridge
Date
Sep 2026
Source
Crypto Training
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
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.
Source & credit. Crypto Training original detection and analysis (live Twitter/X security-alert intake — not from DeFiHackLabs). Standalone Foundry PoC, offline
anvil_state.json, and full write-up: 2026-09-SymbiosisBridgeV2SyBtc_exp in theevm-hack-registrymirror.
Vulnerability classes: vuln/bridge/missing-validation · vuln/logic/missing-validation · vuln/input-validation/missing · vuln/auth/signature-validation
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Source test: test/SymbiosisBridgeV2SyBtc_exp.sol. Verified sources: BridgeV2.sol (signed receive + unconstrained transmitter call), Synthesis.sol (
metaMintSyntheticTokenBTC1:1 mint), SyntFabric.sol, SyntERC20.sol (syBTC).
Key info#
| Loss | Headline ~$336k realized (4.39 WBTC dumped on Ethereum Uni V4). This PoC reproduces the BSC-side unbounded syBTC mint: 4,611,686,018,427,388,234 raw units (8 decimals = 46,116,860,184.27388234 syBTC, 2^62 + 330) output.txt |
| Vulnerable contracts | BridgeV2 proxy 0xb8f275fB…81A8 · impl 0x291A42bD…6608 · Synthesis proxy 0x6B1bbd30…bfaA · impl 0x24f6f8Ee…2dd9 · syBTC 0xA67c48F8…32c7 |
| Attacker | 0x025122b6…3Ba2 (fresh EOA beneficiary; live submitter was the relayer) |
| Attack tx | 0x9a2bc0ac…b959 (BSC block 121,198,122) |
| Chain / block / date | BNB Chain / fork 121,198,121 / 2026-09 |
| Compiler | BridgeV2 / Synthesis impl v0.8.19 (optimizer 2000 runs); SyntFabric / SyntERC20 v0.8.7 |
| Bug class | receiveRequestV2Signed executes arbitrary transmitter calldata if an MPC ECDSA over keccak256("receiveRequestV2"||callData||receiveSide||chainid||bridge) is valid. No amount cap, no BTC inclusion proof, no msg.sender binding, no consumed-hash beyond the caller-chosen externalID. Synthesis then mints amount syBTC 1:1 |
TL;DR#
Symbiosis BridgeV2 treats an MPC signature as sufficient to mint synthetic BTC.
receiveRequestV2Signed(callData, receiveSide, signature) checks SignatureChecker.isValidSignatureNow(mpc(), getRequestHash(...), signature) and then does _receiveSide.call(_callData) with no amount bound. The live payload is Synthesis.metaMintSyntheticTokenBTC with amount = 2^62 + 330, serial = 7923, to = the attacker EOA. Fabric mints that many 8-decimal syBTC and transfers them to to.
The hash does not bind msg.sender, so any account that holds the signature can submit it before externalID / serial is consumed. The on-chain contracts never see a BTC inclusion proof or a max-mint ceiling.
This PoC replays the BSC mint (the smart-contract bug). The later Ethereum Uni V4 dump of ~4.39 WBTC is the cash-out, not reproduced here.
Background#
Symbiosis synthesizes BTC as syBTC on BSC. The intended path is: BTC lock on the origin side → MPC signs a receiveRequestV2 payload → BridgeV2 calls Synthesis → Fabric mints the synthetic representation.
getRequestHash is:
keccak256(bytes.concat(
"receiveRequestV2",
_callData,
bytes20(_receiveSide),
bytes32(block.chainid),
bytes20(address(this))
));
That binds calldata, the transmitter, chain id, and this bridge. It does not bind:
- a protocol-level amount cap
- a BTC transaction / inclusion proof
msg.sender(anyone can broadcast a captured signature)- a dedicated consumed-hash nonce (only the inner
externalIDmapping)
Synthesis.metaMintSyntheticTokenBTC is onlyBridge and checks that realToMintSerialBTC[tokenReal] == serial, then increments. The serial lives inside the signed struct, so a signature over a huge amount at the next serial is enough.
The vulnerable code#
BridgeV2.sol (impl 0x291A42bD…):
function getRequestHash(bytes memory _callData, address _receiveSide) external view returns (bytes32) {
return keccak256(bytes.concat(
"receiveRequestV2", _callData, bytes20(_receiveSide),
bytes32(block.chainid), bytes20(address(this))
));
}
function receiveRequestV2Signed(bytes memory _callData, address _receiveSide, bytes memory signature)
external
onlySignedByMPC(this.getRequestHash(_callData, _receiveSide), signature)
{
_processRequest(_callData, _receiveSide);
}
function _processRequest(bytes memory _callData, address _receiveSide) private {
require(isTransmitter[_receiveSide], "BridgeV2: untrusted transmitter");
(bool success, bytes memory data) = _receiveSide.call(_callData);
if (!success) {
revert(RevertMessageParser.getRevertMessage(data, "BridgeV2: call failed"));
}
}
Synthesis.sol (impl 0x24f6f8Ee…):
function metaMintSyntheticTokenBTC(
MetaRouteStructs.MetaMintTransactionBTC memory _metaMintTransaction
) external onlyBridge whenNotPaused {
require(synthesizeStates[_metaMintTransaction.externalID] == SynthesizeState.Default, "...");
synthesizeStates[_metaMintTransaction.externalID] = SynthesizeState.Synthesized;
// ...
require(realToMintSerialBTC[_metaMintTransaction.tokenReal] == _metaMintTransaction.serial, "Symb: nonsequential mint serial");
realToMintSerialBTC[_metaMintTransaction.tokenReal] = realToMintSerialBTC[_metaMintTransaction.tokenReal].inc();
ISyntFabric(fabric).synthesize(
address(this),
_metaMintTransaction.amount - _metaMintTransaction.stableBridgingFee,
syntReprAddr
);
// fee mint to bridge, then TransferHelper.safeTransfer(syntReprAddr, to, amount)
}
SyntFabric.synthesize is SyntERC20(_stoken).mint(_to, _amount) with no cap.
Live inner values (from test/SymbiosisBridgeV2SyBtc_exp.sol):
| Field | Value |
|---|---|
amount | 4611686018427388234 (2^62 + 330) |
serial | 7923 |
tokenReal | 0x1DfC1e32d75b3f4Cb2F2B1BCEcAD984E99eeba05 |
to | attacker EOA 0x025122b6…3Ba2 |
| MPC | 0x855eeeAe34D08597Db031094efbd8B6D15f849f6 |
Relayer (live msg.sender) | 0x67f9b3E561383493B3f874fEAE0c53c2cD23851D |
Root cause#
- MPC signature is the only mint gate. There is no on-chain amount ceiling, no BTC proof, and no sanity bound versus
2^62-scale values. - Unconstrained
.call(_callData)to any transmitter (Synthesis is one). The bridge does not decode or bound the inneramount. - Request hash omits
msg.sender. A captured signature is submittable by any EOA untilexternalIDis markedSynthesized. - Serial is attacker/MPC-chosen inside the signed struct, not an independent on-chain nonce the signer cannot pick.
- 1:1 synt mint (
SyntFabric.synthesize→SyntERC20.mint) with 8 decimals turns a single bad signature into tens of billions of face syBTC, which can then be bridged / swapped (the Ethereum WBTC dump).
Preconditions#
- BridgeV2 not paused; Synthesis is a registered transmitter;
isTransmitter[Synthesis] == true. realToMintSerialBTC[tokenReal] == 7923(the next serial in the signed payload).synthesizeStates[externalID] == Default(thisexternalIDunused).- A valid MPC ECDSA over
getRequestHash(innerCalldata, Synthesis). - syBTC representation exists for
tokenReal.
Attack walkthrough#
| # | Step | Detail |
|---|---|---|
| 1 | Fork BSC 121,198,121 | Live mint is in the next block. Serial is still 7923. Attacker already holds 46,116,860,184.27388234 syBTC from an earlier mint of the same size |
| 2 | receiveRequestV2Signed(INNER_CALLDATA, Synthesis, MPC_SIGNATURE) | Hash binds calldata + Synthesis + chainid + this bridge. Signature verifies against MPC 0x855eeeAe… |
| 3 | _processRequest | Synthesis.call(metaMintSyntheticTokenBTC(… amount=2^62+330 …)) |
| 4 | metaMintSyntheticTokenBTC | Marks externalID synthesized, checks serial 7923, increments, Fabric mints, transfers syBTC to the EOA |
| 5 | Profit | 4,611,686,018,427,388,234 raw syBTC minted this call. Face after: 92,233,720,368.54776468 |
From output.txt:
MPC: 0x855eeeAe34D08597Db031094efbd8B6D15f849f6
mint serial before: 7923
Minted syBTC (8 decimals): 46116860184.27388234
minted raw: 4611686018427388234
[PASS] testExploit() (gas: 421364)
Cash-out (not in this PoC): the attacker dumped ~4.39 WBTC on Ethereum Uniswap V4 for ~$336k realized.
Diagrams#
Remediation#
- Cap mint amounts on-chain (absolute and per-serial). Reject
amountabove a BTC-realistic ceiling. - Require a BTC inclusion proof (or a light-client / attested lock event) — an MPC signature must not be sufficient to print synthetic BTC.
- Bind
msg.sender(or a dedicated relayer set) and a consumed request hash independent of caller-chosenexternalID. - Decode inner calldata in the bridge and bound
amount/to/tokenRealbefore.call. - Circuit-break when minted face value diverges from observed BTC locks.
How to reproduce#
_shared/run_poc.sh 2026-09-SymbiosisBridgeV2SyBtc_exp --mt testExploit -vvvvv
Expected: [PASS] testExploit() minting 4611686018427388234 raw syBTC (8 decimals).
Reference: https://x.com/blockaid_/status/2098275381417513149
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-09-SymbiosisBridgeV2SyBtc_exp (evm-hack-registry mirror).
- Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- 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.