Skip to main content

Vụ hack Hedgey Finance

· 8 min read

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ượng campaign.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, claimAmountproof 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 contract ClaimCampaigns đư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àm claimTokens.
Merkle Tree

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ưu root hash onchain. Khi user claim thì có thể xác nhận verify (địa chỉ, số lượng, proof) có khớp với campaign root 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 cho claimLockup.tokenLocker, và cũng không kiểm tra input claimLockup.tokenLocker này có đúng là contract TokenLockupPlans đã được deploy hay không nên tokenLocker 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 khi tokenLocker 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ọi claimTokens.
  • 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 cho tokenLocker.

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ừ contract ClaimCampaigns.
    • 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ừ contract ClaimCampaigns.

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 cho tokenLocker số lượng vừa đủ khi người dùng hợp lệ gọi hàm claimToken.
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

https://medium.com/@CUBE3AI/hedgey-finance-hack-detected-by-cube3-ai-minutes-before-exploit-1f500e7052d4