Penulis: Jiujiu

Edit: 77

Latar belakang

Pada 4 September 2026, platform pinjaman terdesentralisasi ternama Notional Finance diserang, dengan kerugian sekitar 1,73 juta dolar AS. Berikut analisis rinci dari tim keamanan SlowMist untuk peristiwa serangan ini:

Pengetahuan pendahuluan

Pada Notional Finance V1, fCash dapat dipahami sebagai piutang kas dengan tanggal jatuh tempo. Cash receiver menerima pembayaran saat jatuh tempo, sedangkan cash payer melakukan pembayaran saat jatuh tempo. Protokol mencatat kedua jenis posisi tersebut di Portfolio, lalu melalui pemeriksaan jaminan bebas (free collateral) ditentukan apakah suatu akun cukup sehat untuk membangun posisi.

safeTransferFrom pada ERC1155Trade di sini berfungsi sebagai pencatatan (ledger) aset. Ketika jenis aset yang dikirim termasuk cash receiver, kontrak akan memanggil Portfolios.mintfCashPair; sekaligus mencatat satu kewajiban untuk payer, dan mencatat satu piutang setara untuk receiver. Panggilan ini tampak seperti transfer ERC1155, tetapi sebenarnya merupakan pencetakan sepasang fCash.

Setelah penulisan posisi baru ke Portfolio, perlu dilakukan perhitungan dan pemeriksaan jaminan bebas (free collateral). Kontrak RiskFramework terlebih dahulu menjumlahkan kewajiban fCash milik payer menjadi int256 bertanda, lalu mengonversi saldo tiap mata uang menjadi ETH untuk kemudian dijumlahkan. Nilai negatif merepresentasikan kas yang harus dibayarkan, sedangkan nilai positif merepresentasikan kas atau piutang yang dimiliki akun. Hasil perhitungan inilah yang menentukan apakah transaksi boleh diizinkan.

Setelah posisi jatuh tempo, Portfolio akan memanggil fungsi portfolioSettleCash dari Escrow, mengubah fCash yang jatuh tempo menjadi saldo kas. Selanjutnya akun mengambil aset DAI atau USDC yang sesuai dari Escrow melalui fungsi penarikan.

Penyebab utama

Celah utama pada serangan ini terletak pada implementasi Escrow dari ExchangeRate._convertToETH, di mana ada bagian kode yang mengubah nilai absolut dari bilangan bulat bertanda langsung menjadi uint128.

Penyerang pertama kali membangun dua posisi kewajiban untuk payer yang sama, dengan nilai masing-masing 1 dan uint128.max. Setelah kedua kewajiban dijumlahkan, hasilnya tepat 2^128. Karena pada perhitungan risiko payer yang dibayarkan dicatat sebagai bilangan negatif, maka parameter kunci yang diteruskan ke convertBalancesToETH milik Escrow adalah -2^128.

Namun kontrak ini menggunakan versi Solidity 0.6.x, sehingga nilai absolut 2^128 yang dipaksa menjadi uint128 akan overflow menjadi 0. Akibatnya, kewajiban ini tidak masuk ke hasil penilaian harga dalam ETH.

Terakhir, pada fungsi mintfCashPair hanya disyaratkan bahwa free collateral milik payer harus lebih besar atau sama dengan nol. Jadi setelah terjadi overflow menjadi 0, pemeriksaan tersebut lolos, dan akun yang tidak memiliki aset cukup untuk menutupi kewajiban tetap dapat membangun posisi.

Analisis langkah-langkah serangan

1. Pada transaksi pendahuluan (0xe1589a19…d60a), penyerang terlebih dahulu membuat beberapa kontrak bantu, lalu memanggil fungsi setApprovalForAl pada kontrak ERC1155Trade untuk memberi otorisasi kepada kontrak bantu tersebut; selain itu, penyerang juga lebih dulu mencari dua tanggal jatuh tempo dari dua CashMarket, menghitung nilai tiga parameter AssetId yang akan diperlukan untuk langkah-langkah berikutnya.

2. Selanjutnya, penyerang mencetak sepasang fCash bernilai 1 dengan memanggil fungsi safeTransferFrom pada kontrak ERC1155Trade. Pasangan ini terkait dengan cashGroupId aset obligasi sebesar 2, dengan timestamp jatuh tempo 1788480000 (4 September 2026 pukul 8:00). Di dalamnya akan memanggil fungsi internal _upsertAsset untuk memperbarui masing-masing kewajiban dan pendapatan yang diharapkan dari alamat from (kontrak penyerang) dan alamat to (kontrak penerima 1).

Setelah pencetakan pair selesai, Portfolios segera memeriksa free collateral dari kontrak penyerang. Karena jumlah pada pencetakan pertama terlalu kecil, saat dikonversi menjadi ETH pada kurs dan presisi saat ini akan dibulatkan menjadi nol, sehingga pemeriksaan pertama dapat lolos.

3. Penyerang kemudian memanggil kembali fungsi safeTransferFrom pada kontrak ERC1155Trade untuk mencetak sepasang fCash dengan nilai sebesar uint128.max. Kali ini, cashGroupId untuk aset obligasi yang bersangkutan adalah 2, tetapi timestamp jatuh tempo justru 1796256000 (3 Desember 2026 pukul 8:00).

Ada detail di sini: penyerang mencetak aset obligasi dengan tanggal jatuh tempo yang berbeda untuk dua alamat yang berbeda. Ini karena saat memperbarui kewajiban pada alamat from, jika aset obligasi yang digunakan sama, maka akan dilakukan penjumlahan langsung; hal ini menyebabkan saat menangani penjumlahan terjadi revert karena overflow (kontrak menggunakan pustaka safeMath).

4. Setelah pencetakan kedua untuk alamat from (kontrak penyerang) dan to (kontrak penerima 2) selesai memperbarui kewajiban dan pendapatan yang diharapkan, fungsi mintfCashPair akan memanggil fungsi freeCollateral untuk menghitung posisi jaminan bersih dari alamat from dan memeriksa bahwa nilainya harus lebih besar atau sama dengan 0 agar dianggap sehat.

Di dalamnya, mula-mula pada fungsi getRequirement di kontrak RiskFrameork, dilakukan penjumlahan dari total kewajiban dua kali pencetakan untuk alamat from:

Hasil akhirnya adalah -2^128. Setelah itu, kontrak Escrow memanggil fungsi convertBalancesToETH untuk mengonversi nilai tersebut menjadi ETH berdasarkan penilaian, dan fungsi convertBalancesToETH lagi-lagi memanggil fungsi _convertToETH dari pustaka ExchangeRate untuk melakukan perhitungan:

Saat ditelusuri ke dalam fungsi _convertToETH, terlihat bahwa ia terlebih dahulu mengambil nilai absolut dari balance, lalu memanggil uint128 untuk mengonversi konversi dari 256-bit menjadi 128-bit. Sementara itu, total nilai kewajiban (liability) dari dari yang bernilai negatif adalah -2^128; setelah diambil absolutnya menjadi 2^128, saat dilakukan konversi paksa uint128, terjadi overflow melewati batas sehingga berakhir menjadi 0. Ini berarti protokol keliru menganggap posisi jaminan bebas (free collateral) dari alamat from sebesar 0 adalah kondisi yang sehat, sehingga lolos pemeriksaan akhir di dalam fungsi mintfCashPair.

5. Setelah itu, kontrak penerima 2 memecah posisinya lagi menjadi dua posisi untuk dua kontrak bantu lainnya; kedua safeTransferFrom ini tetap akan masuk ke fungsi mintfCashPair. Namun pada saat ini, payer adalah kontrak penerima 2 pada pencetakan kedua. Penerima 2 memiliki posisi pendapatan yang sangat besar yang diperoleh dari langkah sebelumnya; perhitungan risiko menganggapnya sebagai piutang positif sehingga lolos pemeriksaan jaminan. Dengan demikian, kedua kontrak bantu memperoleh receiver fCash yang dapat ditukar menjadi kas setelah jatuh tempo.

6. Selanjutnya, penyerang memulai transaksi kedua yang merupakan transaksi resmi untuk memperoleh keuntungan. Dalam transaksi ini, masing-masing kontrak bantu yang memegang dua posisi dari transaksi sebelumnya diselesaikan untuk piutang yang sesuai, sehingga menambah saldo kas pada aset terkait; setelah itu, penyerang memanggil fungsi withdraw pada kontrak Escrow untuk mengambil aset dan mengambil keuntungan.

图片

Ringkasan

Inti dari peristiwa serangan ini adalah penyerang terlebih dahulu menggunakan dua posisi terpisah untuk menjumlahkan total kewajiban payer menjadi 2^128, lalu membuat Escrow mengonversi kewajiban tersebut menjadi ETH. Dengan memanfaatkan celah overflow pada konversi tipe, hasilnya terpotong menjadi nol, sehingga pemeriksaan risiko dinyatakan lolos.

Tim keamanan SlowMist menyarankan agar sebelum melakukan konversi tipe, pihak proyek wajib melakukan pemeriksaan rentang; jika hasilnya melebihi type(uint128).max maka langsung ditolak. Pada saat yang sama, semua batas yang melibatkan nominal bertanda (signed amount), penskalaan presisi, dan nilai denominasi aset harus ditambahkan dengan pengujian ekstrem (extreme-value testing), khususnya mencakup input seperti uint128.max, uint128.max + 1, dan absolut nilai negatif. Anda dapat merujuk atau menggunakan pustaka SafeCast milik Openzeppelin untuk menangani konversi.