EP.3 - Practical Flash loan on Decentralized Finance (DeFi)

thispost
11 min readAug 25, 2021

--

Intro

The Flash loan is also used in the dark side. We’ll give you an example of the Flash loan used for attacking Pancake Bunny. As we’ve known before, the Flash loan is a technique used to facilitate the attack, not a vulnerability. Slowmist has summarized the attack, and some part of the summary is as follows:

The key point is that the price calculation of WBNB-BUNNY LP is flawed, and the number of BUNNY minted by the BunnyMinterV2 contract depends on this flawed LP price calculation method.

After the attack had performed successfully, the Bunny price is dropped by ~90% as shown in Listing 1.

Listing 1 - The Bunny price is dramatically dropped from the successful attacking (source).

In this EP., we’ll only focus on the Flash loan, not the full attacking advisory. We’ll find out How can the attacker conduct the Flash loan on multiple pools (LPs)? Which method does the attacker use for repayment?
We’ll describe Solidity’s storage layout a bit, watch this video is preferably recommended. In addition, the Flash loan from Fortube bank won’t be too much mentioned since it’s not the same as Flash Swap that we’ve introduced in EP1. and EP2.

The operations

The attacker initiated 2 transactions,
1. The attacker called init() on the malicious contract to add liquidity, obtain 9.27 WBNB-BUSDT LP, and deposited to vaultFlipToFlip contract of Pancake Bunny (Tx).
2. The attacker called function ID 0x8566270b to start the attack (Tx). The Flash loan started here, we’ll start focusing on this function ID.
Remark: We’ll give a bit of detail for the Flash loan on USDT from Fortube bank.

Prepare for reverse engineering

We’ll use a combination of panoramix and Tenderly to conduct reverse engineering on the malicious contract and its transaction. Each tool has its own advantage for a particular task.

  • Decompile the malicious code using panoramix command as follows:
root@7bc24df8bf29:/# WEB3_PROVIDER_URI=https://bsc-dataseed.binance.org/ panoramix 0xcc598232a75fB1B361510Bce4Ca39d7bC39cf498

or decompile it on BSCScan (we’ll use this) :

https://bscscan.com/bytecode-decompiler?a=0xcc598232a75fb1b361510bce4ca39d7bc39cf498
  • Use the Tx from 2nd call invoked by the attacker to debug on Tenderly as the following link:
https://dashboard.tenderly.co/tx/bsc/0x897c2de73dd55d7701e1b69ffb3a17b0f4801ced88b0c75fe1551c5fcce6a979

The Flash loan on the attack

1. Loan the first 1.05M WBNB from Cake-WBNB LP

At the 0x8566270b function, the malicious contract checks which token0() or token1() is WBNB on address stored at storage slot number 9 stor9 to prepare the arguments of swap() (this would be performed on all LPs of the Flashloan).

static call stor9.token0() with:
gas gas_remaining wei
if not ext_call.success:
revert with ext_call.return_data[0 len return_data.size]
require return_data.size >= 32
require ext_code.size(stor9)
if ext_call.return_data[12 len 20] != 0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c:
static call stor9.token1() with:
gas gas_remaining wei
if not ext_call.success:
revert with ext_call.return_data[0 len return_data.size]
require return_data.size >= 32
require ext_call.return_data[12 len 20] == 0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c

We obtain the contract’s storage and found that the address in storage stor9 is Cake-WBNB LP address and token1() is WBNB.

> web3.eth.getStorageAt('0xcc598232a75fB1B361510Bce4Ca39d7bC39cf498', 9).then(console.log)
> 0x0000000000000000000000000ed7e52944161450477ee417de9cd3a859b14fd0

Then, the malicious contract starts calling swap() (see the function signature at Listing 1) of the address in storage stor9.

call stor9.0x22c0d9f with:
gas gas_remaining wei
args mem[ceil32(_param2.length) + 196 len 0, 32 + 160]
Listing 1 - swap() signature from 4byte.dictionary.

We can easily detect the first swap() called to Cake-WBNB LP on Tenderly.

Listing 2 - Tx debugging on Tenderly.

If we look at the arguments of swap(), we’ll see the value amount1Out (since token1() is WBNB) is 1051687744972121462519028. This indicates that the malicious contract will loan 1.05M WBNB from Cake-WBNB LP. In addition, the length of data was not equal to 0, the value of data was 0x0, this piece of information is used as a counter by the malicious contract to perform the Flash loan for WBNB on multiple LPs.

Listing 3 - First swap(), loan 1.05M WBNB.

We’ll note that the data is the 4th argument with the value 0x0 (32 bytes) on the first LPs Flash loan.

2. Cake-WBNB LP called back to pancakeCall() on the malicious contract, performed the Flash loan on the other LPs

As we’ve learned in EP.1, the arbitrary code to do with the funds is in pancakeCall(). The function ID 0x84800812 is found in the malicious contract. 0x84800812 is the signature of pancakeCall() as shown in Listing 4. In this step, 1.05M WBNB is already transferred to the malicious contract, but, more WBNB is required on the attacker’s job. The malicious contract performs the Flash loan for more WBNB on the other LPs on this function.

def unknown84800812() payable:
require calldata.size - 4 >= 128
require cd[100] <= 4294967296
require cd[100] + 36 <= calldata.size
Listing 4 - pancakeCall() signature from 4byte.directory.

And again, before calling swap(), the malicious contract checks which token0() or token1() is WBNB. Before the Flash loan on the other LPs is performed, there is if statement to check some values not equal to 7.

if ('cd', 100) + 1 != 7:
require ('cd', 100) + 1 < 7
require ext_code.size(stor10[('cd', 100)]
static call stor10[('cd', 100)].token0() with:
...

Honestly, we don’t exactly know some symbols from panoramix i.e., (‘cd’, 100), we just guess from its code (correct us if we are wrong) as shown in Listing 5. From our guessing, this symbol is CALLDATALOAD and 100 (0x64) is its argument. So, (‘cd’, 100) == CALLDATALOAD(0x64).

Listing 5 - The symbol may relate to (‘cd’, 100).

We have to know, How does CALLDATALOAD() works?
CALLDATALOAD() is used to read 32-byte data from msg.data by index (index is the value from the stack in EVM runtime). It returns the value (index + 32) to the stack as shown in Listing 6.

Listing 6 - CALLDATALOAD input and output from ethervm.

msg.data contains the data of the transaction i.e., the calling method and its arguments. We can illustrate their structure as follows:
1. CALLDATALOAD(0x4), it’s usually the 1st argument because the first 4-byte (byte number 0 - 3) is the function signature. So, the 1st argument length is on index 4 - 31, or 0x4 - 0x23 in Hex.
2. CALLDATALOAD(0x24), the 2nd argument, 0x24 is 32 in Decimal.
3. CALLDATALOAD(0x44), the 3rd argument, and so on.
Note: the argument number n is determined by CALLDATALOAD((20 * (n - 1)) + 4).

So, (‘cd’, 100) or CALLDATALOAD(0x64) may be argument number 4 of msg.data sent back to the malicious contract from Cake-WBNB LP.
To improve our confidence, we’ll copy the msg.data of this step in Tenderly, the raw msg.data is as follows:

Listing 7 - Cake-WBNB LP calls pancakeCall() of the malicious contract.

To decode the msg.data above, we’ll use ethereum-input-data-decoder as the following code:

Listing 8 - Decode parameters from msg.data.

And the result is as follows:

root@7bc24df8bf29:/# node data_decode.js
Argument 1 (address): cc598232a75fb1b361510bce4ca39d7bc39cf498
Argument 2 (uint256): 0
Argument 3 (uint256): 1051687744972121462519028
Argument 4 (bytes): 0000000000000000000000000000000000000000000000000000000000000000

This means the assumption of the following statement may be correct:

if ('cd', 100) + 1 != 7:
//is the same as
if CALLDATALOAD(0x64) + 1 != 7:
// or
if [arg4 of swap()] + 1 != 7:

After we know some symbols of panoramix, we continuously debug it.
The malicious contract calls swap() to perform more Flash loans on the other LPs. The LP address stored at, currently transaction from Cake-WBNB LP, stor10[arg4] == stor10[0].

call stor10[('cd', 100)].0x22c0d9f with:

Storage stor10 is a fixed-size array of addresses. So, the element of the array is stored at slot numbers 10, 11, 12, .., stor10.length.

def storage:
stor0 is uint256 at storage 0
stor1 is array of uint256 at storage 1
stor8 is array of addr at storage 8
stor9 is addr at storage 9
stor10 is array of addr at storage 10

The storage stor10 layout compares with the array be like:
stor10[0] == storage slot 10
stor10[1] == storage slot 11

stor10[5] == storage slot 15

The next LPs to perform the Flash loan is on storage stor10 i,e., WBNB-BUSD LP.

> web3.eth.getStorageAt('0xcc598232a75fb1b361510bce4ca39d7bc39cf498', 10).then(console.log)
> 0x00000000000000000000000058f876857a02d6762e0101bb5c46a8c1ed44dc16

After swap() is performed, WBNB-BUSD LP will call back to pancakeCall() again.

We can summarize that the initiated LP to perform the Flash loan is Cake-WBNB LP in which its address is stored at storage stor9. The other LP addresses are stored at storage stor10 which is the fixed-sized array. The array index of stor10 is obtained from argument number 4 of swap() supplied by the malicious contract.

3. Loan WBNB from the remaining LPs from the addresses in stor10

As we already know that the 2nd LP to perform the Flash loan is WBNB-BUSD LP. We’ll trace what going on for argument 4 of swap() in the next Flash loan. We inspect the Tx that performs the Flash loan on the 2nd LP (WBNB-BUSD), the value is increased by 1.

Listing 9 - swap() to perform the Flash loan on WBNB-BUSD LP.

The last one, loan WBNB from WBNB-DOT LP. The value is 6.

Listing 10 - swap() to perform the Flash loan on WBNB-Polkadot LP.

So, the following line of code is satisfied because WBNB from the last LP (WBNB-DOT LP) is already transferred to the malicious contract since the last swap() of WBNB-DOT LP is called, and unnecessary to call any swap() for another WBNB. In addition, stor10 has only 6 LP addresses.
The flow of execution jumps to else. The Flash loan for WBNB is finished.

if ('cd', 100) + 1 != 7: // CALLDATALOAD(0x64) of WBNB-DOT LP = 0x6...(SNIP)...else: // no more swap() is performed for loaning more WBNB
require ext_code.size(0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c)
static call 0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c.balanceOf(address owner) with:
gas gas_remaining wei
args this.address

The remaining 5 LP addresses that are used to perform the Flash loan for WBNB are as follows:

> const constract = '0xcc598232a75fb1b361510bce4ca39d7bc39cf498'
> web3.eth.getStorageAt(contract, 11).then(console.log)
> 0x00000000000000000000000074e4716e431f45807dcf19f284c7aa99f18a4fbc
> web3.eth.getStorageAt(contract, 12).then(console.log)
> 0x00000000000000000000000061eb789d75a95caa3ff50ed7e47b96c132fec082
> web3.eth.getStorageAt(contract, 13).then(console.log)
> 0x0000000000000000000000009adc6fb78cefa07e13e9294f150c1e8c1dd566c0
> web3.eth.getStorageAt(contract, 14).then(console.log)
> 0x000000000000000000000000f3bc6fc080ffcc30d93df48bfa2aa14b869554bb
> web3.eth.getStorageAt(contract, 15).then(console.log)
> 0x000000000000000000000000dd5bad8f8b360d76d12fda230f8baf42fe0022cf

After that, the malicious contract performs the Flash loan for B-USDT from Fortube bank, we’ll ignore this step.
The summary of the Flash loan is as follows:

Listing 11 - Summary of token transferred of the Flash loan at Tx.

***Not the Flash loan: This is the step to transfer WBNB from the malicious contract to WBNB-B-USDT LP which is one of the attacking processes.

4. Repay

Unlike Loan T-BUSD, repay T-WBNB we’ve demonstrated on EP2, the attacker loaned WBNB and repaid WBNB, the fee is different. On Uniswap, Flash Swap charges 0.3009027% if the user loan and repay the same token, but the fee on PancakeSwap is different (the calculation is the same but the value is different).
The malicious contract stores the loan amount at a dynamic-size array stor1. And we already know that cd symbol is CALLDATALOAD. When WBNB is transferred to the malicious contract, stor1 is increased. if cd > 0 is used to identify which token0 or token1 has an amount (check the amount not equal to 0).

def storage:
stor0 is uint256 at storage 0
stor1 is array of uint256 at storage 1
stor8 is array of addr at storage 8
stor9 is addr at storage 9
stor10 is array of addr at storage 10
...(SNIP)...
def unknown84800812() payable:
...(SNIP)...
stor1.length++
if cd > 0:
stor1[stor1.length] = cd[36] // 36 = 0x24, 2nd argument, amount0Out
else:
stor1[stor1.length] = cd[68] // 68 = 0x44, 3rd argument, amount1Out

Since stor1 is the dynamic-size array, the address of the 1st element is at slot number derived from keccak256(1) as follows:

> web3.utils.soliditySha3(1)
'0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6'

and the n element (assuming n starts with 1) of stor1 is at keccak256(1) + (n - 1). All loan amounts of WBNB at stor1 are as follows:

> web3.eth.getStorageAt(contract, '0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6').then(x=>parseInt(x,16)).then(console.log)
> 1.0516877449721214e+24 // WBNB from Cake-WBNB
> web3.eth.getStorageAt(contract, '0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf7').then(x=>parseInt(x,16)).then(console.log)
> 5.225240386502826e+23 // WBNB from WBNB-BUSD
> web3.eth.getStorageAt(contract, '0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf8').then(x=>parseInt(x,16)).then(console.log)
> 2.1015859837067506e+23 // WBNB from WBNB-ETH
> web3.eth.getStorageAt(contract, '0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf9').then(x=>parseInt(x,16)).then(console.log)
> 1.3350475326709605e+23 // WBNB from WBNB-BTCB
> web3.eth.getStorageAt(contract, '0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cfa').then(x=>parseInt(x,16)).then(console.log)
> 2.41021487428195e+23 // WBNB from WBNB-Safemoon
> web3.eth.getStorageAt(contract, '0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cfb').then(x=>parseInt(x,16)).then(console.log)
> 9.818967748211026e+22 // WBNB from WBNB-Belt
> web3.eth.getStorageAt(contract, '0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cfc').then(x=>parseInt(x,16)).then(console.log)
> 6.629023389677347e+22 // WBNB from WBNB-DOT

After the attack had been performed successfully, the malicious contract will repay WBNB and B-USDT to Pancake LP and Fortube bank (again, will ignore it) respectively.
The repayment code has the steps as follows,

1 . The addresses to repay are stored in the fixed-size array at storage slot number 8 stor8,

> web3.eth.getStorageAt(contract, 8).then(console.log)
> 0x000000000000000000000000dd5bad8f8b360d76d12fda230f8baf42fe0022cf //WBNB-DOT
..
..
> web3.eth.getStorageAt(contract, 2).then(console.log)
> 0x0000000000000000000000000ed7e52944161450477ee417de9cd3a859b14fd0
//Cake-WBNB

and it’s iterate by the indexes at stor1 above (7 elements).

> web3.eth.getStorageAt(contract, 1).then(console.log)
> 0x0000000000000000000000000000000000000000000000000000000000000007

2. Check whether the address is Pancake LP or not by calling factory().
3. Pancake’s Factory is hard-coded as 0xca143ce32fe78f1f7019d7d551a6402fc5350c73, and all 7 WBNB-XXX LPs are Pancake LP. Hence, factory() of 7 WBNB-XXX LPs is 0xca143ce32fe78f1f7019d7d551a6402fc5350c73.
4. Transfer WBNB to 7 WBNB-XXX LPs with an additional fee 0.260677%. Normally, PancakeSwapV2 charges 0.25062% of the fee for the Flash loan that loans and repays the same token (Source: L477–478). This repayment fee is more than usual ~0.01%.

idx = stor1.length  //1.indexes obtained from stor1 to use in stor8
while idx > 0:
require idx - 1 < 7
require ext_code.size(stor8[idx])
static call stor8[idx].factory() with: //2.call to factory()
gas gas_remaining wei
if not ext_call.success:
revert with ext_call.return_data[0 len return_data.size]
require return_data.size >= 32
require idx - 1 < stor1.length
mem[0] = 1
mem[100] = stor8[idx]
if addr(ext_call.return_data) == 0xca143ce32fe78f1f7019d7d551a6402fc5350c73: //3. Pancake's Factory
mem[132] = 10000 * stor1[idx] / 9974
require ext_code.size(0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c)
call 0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c.transfer(address to, uint256 value) with: //4.repay WBNB
gas gas_remaining wei
args stor8[idx], 10000 * stor1[idx] / 9974 //repay args

The last LP that performed Flash loan has to repay first. In this case, repay B-USDT to Fortube Bank that we’ll ignore to mention.
Next WBNB-DOT, WBNB-Belt, WBNB-Safemoon, WBNB-BTCB, WBNB-ETH, WBNB-BUSD, and finally Cake-WBNB. The repayment sequence is the reversion of the Flash loans sequence.
To confirm our assuming fee is correct, we’ll calculate and compare it with the Tx.
The calculations are as follows:

> var BN = web3.utils.BN;
> let numerator = new BN('10000')
> let denominator = new BN('9974') // fee 0.26%
> let loan_WBNB_DOT = new BN('1051687744972121462519028')
> loan_WBNB_DOT * numerator / denominator
6.646303779504058e+22 //WBNB amount to repay to WBNB-DOT
> let loan_WBNB_Belt = new BN('98189677482110262659207')
> loan_WBNB_Belt * numerator / denominator
9.844563613606402e+22
//WBNB amount to repay to WBNB-Belt
> let loan_WBNB_Safemoon = new BN('241021487428194992833789')
> loan_WBNB_Safemoon * numerator / denominator
2.416497768479998e+23 //WBNB amount to repay to WBNB-Safemoon
> let loan_WBNB_BTCB = new BN('133504753267096047023128')
> loan_WBNB_BTCB * numerator / denominator
1.3385277047031888e+23 //WBNB amount to repay to WBNB-BTCB
> let loan_WBNB_ETH = new BN('21015859837067504867319')
> loan_WBNB_ETH * numerator / denominator
2.1070643510194005e+22 //WBNB amount to repay to WBNB-ETH
> let loan_WBNB_BUSD = new BN('522524038650282621597596')
> loan_WBNB_BUSD * numerator / denominator
5.2388614262109745e+23 //WBNB amount to repay to WBNB-BUSD
> let loan_Cake_WBNB = new BN('1051687744972121462519028')
> loan_Cake_WBNB * numerator / denominator
1.0544292610508536e+24 //WBNB amount to repay to Cake-WBNB

and our calculated repayment amounts are exactly matched with the actual repayment amount in Tx as shown in Listing 12.

Listing 12 - The repayment amount of WBNB on 7 LPs.

Outro

This last EP. is very detailed because we need to conduct reverse engineering on the malicious contract. We are not sure about some decompiled code from panoramix, we debug and see the possible transaction’s values using Tenderly together. So, it’s might not be 100% correct.

--

--