Audius Protocol Exploit
Vụ này cũng lâu rồi, bài này mình đã draft hơn 6 tháng trước nhưng do lười quá nên chưa hoàn thành được, nay có động lực ngồi tìm hiểu nốt và viết ra đây. Dù vụ này không còn hot nhưng mình muốn hoàn thành nó để có được kiến thức từ nó, nhỡ đâu sau này cần dùng.
Hacker thực hiện 4 tx chính trong vụ hack này:
-
Giải quyết các proposal còn đang tồn đọng (proposal 82, 83) trong Governance của AUDIUS +
initialize()
lại các contract Governance, DelegateManagerV2, Staking để hacker có được quyền tạo proposal, gỉa mạo stake để có trọng lượng vote lớn + tạo proposal (proposal 84)rút tiền ra khỏi Governance tại tx -
Hacker nhận ra
AttackContract
hiện tại bị lỗi nên tạo 1AttackContract
khác + giải quyết luôn proposal 84 và đương nhiên proposal này không được thực thi +initialize()
lại DelegateManagerV2, Governance + Staking + tạo proposal rút tiền mới (proposal 85) tại tx -
Với lượng stake giả mạo cực lớn, Hacker vote Yes cho proposal 85 tại tx
-
Hacker
evaluateProposalOutcome()
cho proposal 85 để nó được thực thi và hơn 18M AUDIO chuyển vềAttackContract
của hacker tại tx
Các bạn xem link 4 của 4 tx ở trên, click vào tất cả những chỗ có thể click được để tìm hiểu sâu hơn.
Nguyên nhân
Do sử dụng Proxy không cẩn thận, storage bị ghi đè, gây ra lỗi logic nghiêm trọng.
Proxy mà Audius sử dụng cho các modules của mình có source code như sau:
pragma solidity ^0.5.0; import "@openzeppelin/upgrades/contracts/upgradeability/UpgradeabilityProxy.sol"; /** * @notice Wrapper around OpenZeppelin's UpgradeabilityProxy contract. * Permissions proxy upgrade logic to Audius Governance contract. * https://github.com/OpenZeppelin/openzeppelin-sdk/blob/release/2.8/packages/lib/contracts/upgradeability/UpgradeabilityProxy.sol * @dev Any logic contract that has a signature clash with this proxy contract will be unable to call those functions * Please ensure logic contract functions do not share a signature with any functions defined in this file */
contract AudiusAdminUpgradeabilityProxy is UpgradeabilityProxy { address private proxyAdmin; string private constant ERROR_ONLY_ADMIN = ( "AudiusAdminUpgradeabilityProxy: Caller must be current proxy admin" ); /** * @notice Sets admin address for future upgrades * @param _logic - address of underlying logic contract. * Passed to UpgradeabilityProxy constructor. * @param _proxyAdmin - address of proxy admin * Set to governance contract address for all non-governance contracts * Governance is deployed and upgraded to have own address as admin * @param _data - data of function to be called on logic contract. * Passed to UpgradeabilityProxy constructor. */ constructor( address _logic, address _proxyAdmin, bytes memory _data ) public payable UpgradeabilityProxy(_logic, _data) { proxyAdmin = _proxyAdmin; } /** * @notice Upgrade the address of the logic contract for this proxy * @dev Recreation of AdminUpgradeabilityProxy._upgradeTo. * Adds a check to ensure msg.sender is the Audius Proxy Admin * @param _newImplementation - new address of logic contract that the proxy will point to */ function upgradeTo(address _newImplementation) external { require(msg.sender == proxyAdmin, ERROR_ONLY_ADMIN); _upgradeTo(_newImplementation); } /** * @return Current proxy admin address */ function getAudiusProxyAdminAddress() external view returns (address) { return proxyAdmin; } /** * @return The address of the implementation. */ function implementation() external view returns (address) { return _implementation(); } /** * @notice Set the Audius Proxy Admin * @dev Only callable by current proxy admin address * @param _adminAddress - new admin address */ function setAudiusProxyAdminAddress(address _adminAddress) external { require(msg.sender == proxyAdmin, ERROR_ONLY_ADMIN); proxyAdmin = _adminAddress; }
}
Về nguyên tắc, chúng ta không nên bất cứu một giá trị ở một slot nhất định trong contract Proxy, nhưng ở đây họ đã lưu giá trị proxyAdmin
ngay tại slot 0
(biến này chiếm 160/256 bit slot 0
), điều này dẫn đến một sai lầm chết người.
Chúng ta xem qua logic của Governance
trước khi bị hack của họ, tại địa chỉ 0x35dd16dfa4ea1522c29ddd087e8f076cad0ae5e8 rút gọn như sau:
pragma solidity ^0.5.0; import "@openzeppelin/contracts-ethereum-package/contracts/math/SafeMath.sol";
import "./Staking.sol";
import "./ServiceProviderFactory.sol";
import "./DelegateManager.sol";
import "./registry/Registry.sol";
import "./InitializableV2.sol"; contract Governance is InitializableV2 { ... function initialize( address _registryAddress, uint256 _votingPeriod, uint256 _executionDelay, uint256 _votingQuorumPercent, uint16 _maxInProgressProposals, address _guardianAddress ) public initializer { } ...
}
Trong đó, Governance
kế thừa InitializableV2
, còn InitializableV2
Initializable
của Openzeppelin.
pragma solidity >=0.4.24 <0.7.0; import "@openzeppelin/upgrades/contracts/Initializable.sol"; contract InitializableV2 is Initializable { ...
}
pragma solidity >=0.4.24 <0.7.0; /** * @title Initializable * * @dev Helper contract to support initializer functions. To use it, replace * the constructor with a function that has the `initializer` modifier. * WARNING: Unlike constructors, initializer functions must be manually * invoked. This applies both to deploying an Initializable contract, as well * as extending an Initializable contract via inheritance. * WARNING: When used with inheritance, manual care must be taken to not invoke * a parent initializer twice, or ensure that all initializers are idempotent, * because this is not dealt with automatically as with constructors. */
contract Initializable { /** * @dev Indicates that the contract has been initialized. */ bool private initialized; /** * @dev Indicates that the contract is in the process of being initialized. */ bool private initializing; /** * @dev Modifier to use in the initializer function of a contract. */ modifier initializer() { require(initializing || isConstructor() || !initialized, "Contract instance has already been initialized"); bool isTopLevelCall = !initializing; if (isTopLevelCall) { initializing = true; initialized = true; } _; if (isTopLevelCall) { initializing = false; } } /// @dev Returns true if and only if the function is running in the constructor function isConstructor() private view returns (bool) { // extcodesize checks the size of the code stored in an address, and // address returns the current address. Since the code is still not // deployed when running a constructor, any checks on its code size will // yield zero, making it an effective way to detect if a contract is // under construction or not. address self = address(this); uint256 cs; assembly { cs := extcodesize(self) } return cs == 0; } // Reserved storage space to allow for layout changes in the future. uint256[50] private ______gap;
}
Như vậy tóm lại ta có thể hiểu storage
của Governance được sắp xếp như sau:
pragma solidity ^0.5.0; contract Governance { /** * @dev Indicates that the contract has been initialized. */ bool private initialized; /** * @dev Indicates that the contract is in the process of being initialized. */ bool private initializing; ...
}
Cả 2 biến initialized
và initializing
chỉ chiếm 16 / 256 bit của slot 0
(kiểu dữ liệu bool cần 8 bit để lưu trong storage).
Do đó khi áp dụng Proxy
cho Governance
tại 0x4deca517d6817b6510798b7328f2314d3003abac đã gây ra ghi đè storage, cụ thể, một khi biến proxyAdmin
được set trong Proxy
, ngay lập tức biến initializing
được gán bằng true
, điều này chỉ không xảy ra khi may mắn address của proxyAdmin
có vài số 0 đặc biệt mà khi lưu vào các số 0 đấy nằm vừa lọt với 8 bit của biến initializing
.
Nhìn lại initializer
modifer của Initializable
.
modifier initializer() { require(initializing || isConstructor() || !initialized, "Contract instance has already been initialized"); bool isTopLevelCall = !initializing; if (isTopLevelCall) { initializing = true; initialized = true; } _; if (isTopLevelCall) { initializing = false; } }
Mục đích ban đầu của modifier
này là cho phép hàm initialize()
trong contract Governance
chỉ được gọi một lần đầu tiên và duy nhất bởi phía Audius team. Nhưng do biến initializing
đã được vô tình đặt thành true
nên modifer này luôn pass qua. Vì thế ở Governance
, hacker có thể gọi lại hàm initialize()
bất cứ lúc nào và bất cứ tham số nào mà hacker muốn.
pragma solidity ^0.5.0; import "@openzeppelin/contracts-ethereum-package/contracts/math/SafeMath.sol";
import "./Staking.sol";
import "./ServiceProviderFactory.sol";
import "./DelegateManager.sol";
import "./registry/Registry.sol";
import "./InitializableV2.sol"; contract Governance is InitializableV2 { ... function initialize( address _registryAddress, uint256 _votingPeriod, uint256 _executionDelay, uint256 _votingQuorumPercent, uint16 _maxInProgressProposals, address _guardianAddress ) public initializer { } ...
}
Không chỉ mỗi module Governance
mà hầu như tất cả modules khác như DelegateManager và Staking, Registry đều dùng AudiusAdminUpgradeabilityProxy
chết người đó, do đó hacker đã thâm nhập và thay đổi tham số quan trọng của chúng.
Hacker đã làm theo trình tự như thế nào.
Bước 1
Hacker xác định mục tiêu là contract Governance, trước khi bị tấn công, contract này nắm giữ hơn 18M AUDIO. https://etherscan.io/address/0x4DEcA517D6817B6510798b7328F2314d3003AbAC#tokentxns
Hacker sẽ tìm cách chuyển 18M AUDIO này ra khỏi Governance thông qua tạo 1 proposal thực hiện chuyển AUDIO ra khỏi Governance, vote cho proposal và cuối cùng là thực thi proposal.
Bước 2
Xác định các điều kiện cần phải vượt qua
Nhìn vào code của function submitProposal() của Governance:
Điều kiện 1
Tất cả các proposal trước đó, nếu đã đủ điều kiện để đánh giá thì chúng phải được đánh giá trước khi có 1 proposal khác được submit.
Ở thời điểm đó, Hacker thấy trên Governance đã đủ điều kiện nhưng chưa được đánh giá là proposal 82 và 83, do đó hắn đã gọi evaluateProposalOutcome(82), evaluateProposalOutcome(83) của Governance.
Điều kiện 2
Người submit proposal phải là guardianAddress
hoặc người đó có 1 lượng token đang được stake
Để làm được điều này, Hacker đã giả mạo guardianAddress
thông qua hàm initialize()
của Governance.
Lúc này Guardian giả mạo là 0xA62c3ced6906B188A4d4A3c981B79f2AABf2107F cũng chính là địa chỉ của AttackContract
.
Điều kiện 3
targetContractRegistryKey
phải tồn tại trên Registry để xác định được targetContractAddress
, tuy nhiên Registry của AUDIUS không tồn tại key này nên hacker đã giả mạo luôn Registry thông qua initialize()
của Governance.
Và contract giả mạo Registry phải có code như sau:
contract AttackContract { address audio = address(0x18aAA7115705e8be94bfFEBDE57Af9BFc265B998); ... function getContract(bytes32 _name) external view returns (address contractAddr) { return audio; } ...
}
Sau khi vượt qua các điều kiện, nội dung của proposal như sau:
- với
targetContractRegistryKey
như trên thì Registry giả mạo của Hacker sẽ trả về địa chỉ AUDIO token
functionSignature
: đại điện cho hàmtransfer()
của AUDIO tokencallData
: là dạng hex của receiver address + số lượngname
vàdescription
:Hello World
huyền thoại
Điều kiện 4
Sau khi proposal trên được submit, hacker phải tìm cách vote Yes
cho proposal đó, và cái vote đó phải đủ trọng lượng để thực thi proposal.
Governance tính trọng lượng của 1 vote bằng số token AUDIO mà voter đang stake trong contract Staking thông qua contract DelegateManager.
- Governance
- Hàm
getTotalDelegatorStake()
của DelegateManager
À, sau khi submit proposal 84, có lẽ Hacker đã nhận ra AttackContract vẫn còn thiếu gì đấy nên đang cập nhật lại AttackerContract tại địa chỉ 0xbdbB5945f252bc3466A319CDcC3EE8056bf2e569, không vote cho proposal 84 mà evaluate nó luôn để nó không được thực thi, sau đó tạo một proposal mới có id là 85.
Quay lại với submit vote, để có thể vote cho proposal 85 mà trong tay đang không có 1 token AUDIO nào, Hacker initialize()
để giả mạo token AUDIO và Governance
- Staking contract
Như vậy, AttackContract
lúc này có dạng như sau:
contract AttackContract { address audio = address(0x18aAA7115705e8be94bfFEBDE57Af9BFc265B998); ... function getContract(bytes32 _name) external view returns (address contractAddr) { return audio; } function isGovernanceAddress() external returns (bool) { return true; } ...
}
Sau đó, Hacker initialize()
lại contract DelegateManager
- DelegateManagerV2 contract:
Như vậy, AttackContract
lúc này có dạng:
contract AttackContract { address audio = address(0x18aAA7115705e8be94bfFEBDE57Af9BFc265B998); ... function getContract(bytes32 _name) external view returns (address contractAddr) { return audio; } function isGovernanceAddress() external returns (bool) { return true; } function getVotingPeriod() external returns (uint256) { return 0; } function getExecutionDelay() external returns (uint256) { return 0; } ...
}
Sau đó Hacker delegateStake()
của DelegateManager contract để thực hiện giả mạo stake 1 lượng cực lớn AUDIO
-
delegateStake()
của DelegateManager: -
delegateStakeFor()
của Staking:
_stakeFor()
của Staking:
Do stakingToken
đã bị Hacker thay thế bằng contract AttackContract
của mình nên AttackContract
lúc này có dạng:
contract AttackContract { address audio = address(0x18aAA7115705e8be94bfFEBDE57Af9BFc265B998); ... function getContract(bytes32 _name) external view returns (address contractAddr) { return audio; } function isGovernanceAddress() external returns (bool) { return true; } function getVotingPeriod() external returns (uint256) { return 0; } function getExecutionDelay() external returns (uint256) { return 0; } function transferFrom(address account, address receiver, uint256 amount) returns (bool) { return true; } ...
}
Sau khi đã tạo được proposal, giả mạo một lượng stake cực lớn, Hacker thực hiện vote Yes cho proposal đó
- _vote: 2 là vote Yes, 0 là None, 1 là No
Cuối cùng, Hacker cho evaluateProposalOutcome()
cho proposalId 85 và hơn 18M AUDIO đã chuyển về AttackContract
của hacker.
Bước 3
Chuẩn bị AttackerContract
và tấn công, ở bước này hôm nào mình học xong foundry
mình sẽ viết test tái hiện lại ở local hoàn toàn bằng solidity chứ không dùng hardhat như trước nữa; tái hiện sẽ bỏ qua việc Governance đang có proposal tồn đọng.