Vụ khai thác lỗ hổng contract ClaimCampaigns vào ngày 2024-04-19.
Code trên github ở thời điểm viết bài này là bản đã sửa lỗi, chúng ta có thể check contract nguyên bản chứa lỗ hổng trên etherscan.
Hedgey finance
Hedgey finance là dịch vụ web3 cho phép khóa token ERC20 bất kỳ và trả token theo thời gian. Việc khóa token và quyền rút theo thời gian được đại diện bởi NFT có thể trao đổi được (hoặc không tùy chọn).
TokenLockupPlans
Người dùng tương tác với contract TokenLockupPlans để khóa token và tạo lịch trình rút dần theo thời gian.
function createPlan(
address recipient, // địa chỉ nhận
address token, // token ERC20
uint256 amount, // tổng số token
uint256 start, // thời gian bắt đầu
uint256 cliff, // token ko được mở khóa trước thời điểm này
uint256 rate, // số token cho mỗi lần mở khóa
uint256 period // thời gian giữa những lần mở khóa, tính bằng giây
) external nonReentrant returns (uint256 newPlanId) {...}
Contract TokenLockupPlans
sẽ thực hiện lấy token
từ msg.sender
bỏ vào địa chỉ của mình, do đó TokenLockupPlans
cần được approve quyền sử dụng token của người gọi (hoặc contract) msg.sender
.
Sau đó, người nhận recipient
sẽ nhận về 1 NFT được mint ra và quản lý bởi contract TokenLockupPlans
. Người sở hữu NFT có quyền rút token
tương ứng theo lịch đã set, hoặc trao đổi mua bán trên thị trường tự do để chuyển quyền rút token cho người khác.
ClaimCampaigns
Hedgey finance tạo ra thêm 1 contract ClaimCampaigns để giúp người chủ sở hữu token tạo ra chiến dịch (campaign) với nhiều lockup plan được tạo ra tự động khi người dùng claim (gọi đến TokenLockupPlans.createPlan(...)
).
1. Hàm createLockedCampaign
// kiểu TokenLockup
enum TokenLockup {
Unlocked,
Locked,
Vesting
}
// thông tin chiến dịch
struct Campaign {
address manager; // người quản lý chiến dịch
address token; // token ERC20
uint256 amount; // số lượng token cho chiến dịch
uint256 end; // thời gian kết thúc chiến dịch
TokenLockup tokenLockup; // trong trường hợp này sử dụng kiểu 'Locked'
bytes32 root; // merkle tree root, tất cả địa chỉ claim đã được định sẵn bởi merkle tree
}
// thông tin khóa, nhận token
struct ClaimLockup {
address tokenLocker; // địa chỉ contract TokenLockupPlans đã đề cập ở trên
uint256 start; // từ đây là các thông số về lock token
uint256 cliff;
uint256 period;
uint256 periods;
}
function createLockedCampaign(
bytes16 id, // UUID/CID của file chứa merkle tree trên IPFS
Campaign memory campaign, // thông tin chiến dịch
ClaimLockup memory claimLockup, // thông tin về tokenLockup
Donation memory donation // thông tin về donate cho dịch vụ (tạm bỏ qua)
) external nonReentrant {...}
Người dùng gọi hàm createLockedCampaign
để tạo chiến dịch khóa và nhận token cho các account/số lượng được định sẵn bởi merkle tree.
- Contract chỉ thực hiện check
claimLockup.tokenLocker
không phải làaddress(0)
. Lỗ hổng nằm ở đây. - Contract thực hiện lấy
campaign.token
số lượngcampaign.amount
từmsg.sender
bỏ vào địa chỉ của mình. - Contract trao cho
claimLockup.tokenLocker
quyền sử dụng toàn bộcampaign.amount
token vừa chuyển vào. Lỗ hổng nằm ở đây. - Lưu lại
campaign
vào storage.
2. Hàm claimTokens
Tiếp theo chúng ta giải thích hàm claimTokens
, được gọi khi người dùng muốn claim token của mình bằng cách tạo plan và nhận NFT từ contract TokenLockupPlans
.
Hàm này không trực tiếp được sử dụng khi hacker khai thác lỗi nhưng nên hiểu để lý giải được việc logic code không được chặt chẽ ở chỗ nào cũng như cách vá lỗi.
function claimTokens(bytes16 campaignId, bytes32[] memory proof, uint256 claimAmount) external nonReentrant {...}
-
Đầu tiên hàm kiểm tra verify
msg.sender
,claimAmount
vàproof
có khớp với campaign đã được tạo hay không (đúng địa chỉ và số lượng được chỉ định trong campaign). -
Sau khi kiểm tra ok contract thực hiện gọi
TokenLockupPlans(c.tokenLocker).createPlan(...)
để- contract
TokenLockupPlans
lấy token của contractClaimCampaigns
đưa vào địa chỉ của mình (điều này lý giải tại sao cần bước trao quyền sử dụng token ở trên). - contract
TokenLockupPlans
mint NFT trao cho người gọi hàmclaimTokens
.
- contract
Chỗ này chưa chắc chắn lắm về merkle tree nhưng theo mình cách lưu trữ campaign như sau
- Một campaign đã định sẵn các account được nhận token với số lượng xác định.
- Thay vì lưu 1 mảng các
(account, số lượng)
tốn nhiều storage thì tạo ra 1 merkle tree lưu trên IPFS và chỉ lưuroot hash
onchain. Khi user claim thì có thể xác nhận verify(địa chỉ, số lượng, proof)
có khớp với campaignroot hash
hay không.
3. Hàm cancelCampaign
function cancelCampaign(bytes16 campaignId) external nonReentrant {...}
Người tạo chiến dịch có thể hủy chiến dịch và thu lại toàn bộ số token đã nạp vào (hoặc còn lại chưa claim). Lỗ hổng tiếp tục nằm ở đây, sau khi trả lại toàn bộ số token của campaign, contract ClaimCampaigns
không thu hồi quyền sử dụng số token đã trao cho tokenLocker
.
4. Vấn đề logic chưa chặt chẽ
- Ngay sau khi tạo campaign và thu vào số lượng token, contract
ClaimCampaigns
đã ngay lập tức trao hết quyền sử dụng số token này choclaimLockup.tokenLocker
, và cũng không kiểm tra inputclaimLockup.tokenLocker
này có đúng là contractTokenLockupPlans
đã được deploy hay không nêntokenLocker
có thể là 1 địa chỉ bất kỳ. Thêm nữa việc trao quyền ở bước này là hơi sớm khitokenLocker
chỉ cần quyền sử dụng token với 1 số lượng xác định (không phải toàn bộ) khi người dùng được chỉ định gọiclaimTokens
. - Sau khi hủy chiến dịch và trả lại token, contract
ClaimCampaigns
không thu hồi quyền sử dụng số token đã trao chotokenLocker
.
Thực hiện vụ hack
Sau khi nắm được lỗ hổng hacker có thể dễ dàng thực hiện khai thác lỗi để rút tiền từ contract ClaimCampaigns
.
- Nhắm đến các campaign đã được tạo bởi người dùng thông thường, và số token còn lại của campaign còn khá lớn, cụ thể $1.3M USDC
- Hacker thực hiện flashLoan
- Vay $1.3M USDC từ Balancer
- Tạo campaign với $1.3M USDC với input
tokenLocker
chính là địa chỉ ví của mình để nhận được quyền sử dụng $1.3M USDC từ contractClaimCampaigns
. - Hủy campaign để thu về $1.3M USDC trả lại khoản vay cho Balancer, trong khi vẫn còn nguyên quyền sử dụng $1.3M USDC từ contract
ClaimCampaigns
.
- Dùng địa chỉ
tokenLocker
giả của mình để rút $1.3M USDC từ contractClaimCampaigns
.
Ngoài ra hacker còn có thể nhắm đến các token ERC20 khác đang được nắm giữ bởi contract ClaimCampaigns
.
Hedgey finance vá lỗi
Như đã giải thích ở trên, Hedgey finance sửa lỗi bằng cách
- Khi deploy contract
ClaimCampaigns
thêm vào danh sách whitelist các địa chỉtokenLockers
hợp lệ. - Khi tạo campaign thực hiện kiểm tra địa chỉ
tokenLocker
truyền vào có phải hợp lệ hay không. - Không trao quyền cho
tokenLocker
toàn bộ số token ngay khi tạo campaign, mà chỉ trao quyền chotokenLocker
số lượng vừa đủ khi người dùng hợp lệ gọi hàmclaimToken
.
contract ClaimCampaigns is ReentrancyGuard {
// whitelist tokenLockers hợp lệ
mapping(address => bool) public tokenLockers;
constructor(address _donationCollector, address[] memory _tokenLockers) {
donationCollector = _donationCollector;
// thêm whitelist khi deploy contract
for (uint256 i = 0; i < _tokenLockers.length; i++) {
tokenLockers[_tokenLockers[i]] = true;
}
}
function createLockedCampaign(
bytes16 id,
Campaign memory campaign,
ClaimLockup memory claimLockup,
Donation memory donation
) external nonReentrant {
// ...
// kiểm tra tokenLocker hợp lệ
require(tokenLockers[claimLockup.tokenLocker], 'invalide locker');
TransferHelper.transferTokens(campaign.token, msg.sender, address(this), campaign.amount + donation.amount);
// ...
// xóa bỏ dòng này
// SafeERC20.safeIncreaseAllowance(IERC20(campaign.token), claimLockup.tokenLocker, campaign.amount);
}
function claimTokens(bytes16 campaignId, bytes32[] memory proof, uint256 claimAmount) external nonReentrant {
// ...
// Thay vào đó chỉ trao quyền cho 'tokenLocker' số lượng vừa đủ ngay khi cần sử dụng
SafeERC20.safeIncreaseAllowance(IERC20(campaign.token), c.tokenLocker, claimAmount);
if (campaign.tokenLockup == TokenLockup.Locked) {
ILockupPlans(c.tokenLocker).createPlan(msg.sender, campaign.token, claimAmount, start, c.cliff, rate, c.period);
} else {
IVestingPlans(c.tokenLocker).createPlan(...);
}
}
}
Đồng thời khuyến nghị người dùng hủy toàn bộ các campaign hiện có trên contract ClaimCampaigns
chứa lỗi để rút về số tài sản còn lại.
Link tham khảo