ickb / v1-core

iCKB scripts and utilities for interacting with them
MIT License
2 stars 0 forks source link

An attack that could extract additional CKB from the DAO #12

Closed mohanson closed 4 weeks ago

mohanson commented 4 weeks ago

The attack exploits the fact that integer division always rounds down. Attack happens here: https://github.com/ickb/v1-core/blob/master/scripts/contracts/ickb_logic/src/entry.rs#L76

The attack mainly occurs in the withdraw phase.

At this time, on the Nervos mainnet, the value of dao_ar is 11509953685250771.

  1. Imagine I have 1000 ickb, then according to the formula, my real ckb value is 1150.995368525077
  2. but I can construct a withdrawal request to extract 1152 ckb (because 1152 * 10000000000000000 / 11509953685250771 == 1000) and withdraw successfully
  3. Then, I deposit 1151 ckb back to 1000 ickb (1151 * 100000000000000000 / 11509953685250771 == 1000)
  4. At this point, I have 1000 ickb and an additional 1 ckb. Considering CKB's tx fee is very low, which is profitable.

This is the simplest way to use this code to attack ickb. By constructing transactions in a clever way, I think it is also possible to mint ickb infinitely (1000 ickb => 1152 ckb => 1001 ickb, e.g).

phroi commented 4 weeks ago

Hey @mohanson, thank you for publicly expressing your interest in iCKB by reviewing the L1 scripts source code, I personally appreciate a lot!! 🙏

This is an interesting hypothetical failure mode, pretty neat, keep this kind of issues coming!!

This attack in particular was already considered when I implemented the contracts, so let's examine together how and why it would not succeed.

I can construct a withdrawal request to extract 1152 ckb (because 1152 * 10000000000000000 / 11509953685250771 == 1000) and withdraw successfully

If ever you tried out the testnet DApp, you may get the impression that a withdrawal request of any size can occur, then again this is an abstraction that only works thanks to the limit order script & fulfillment bot, while the underlying core iCKB protocol works very differently. Also limit order script do not use division, so this attack doesn't apply there.

As described in the Withdrawal section of the iCKB proposal, an iCKB withdrawal request can choose from the iCKB Deposit pool from 1 up to 64 (64 only in a no change cells tx) of iCKB Deposits to withdraw from.

How can you be sure that deposit of exactly 1000 iCKB exists?

It would be unlikely that such deposit exist as a user creating it would lose 82 CKB in unaccounted unoccupied capacity by design, around 7% of the deposited amount.

Side Note about lose 82 CKB: If the user user creates the deposit and right after he withdraws from it, nothing is lost. Then again if someone else use that deposit to withdraw, then those 82 CKB are lost forever to the other user. This is by design to incentivize standard deposits.

Moving on, let's assume that by miracle a multitude of 1000 iCKB deposits exists, the attack still does not apply.

If you notice in the iCKB Script, the information always flow is one direction, from the CKB value to the iCKB value, never the other way around:

fn deposit_to_ickb(index: usize, source: Source, amount: u64) -> Result<u128, Error> {
    let amount = u128::from(amount);
    let ar_0 = GENESIS_ACCUMULATED_RATE;
    let ar_m = u128::from(extract_accumulated_rate(index, source)?);

    let ickb_amount = amount * ar_0 / ar_m;

    // Apply a 10% discount for the amount exceeding the soft iCKB cap per deposit
    if ickb_amount > ICKB_SOFT_CAP_PER_DEPOSIT {
        return Ok(ickb_amount - (ickb_amount - ICKB_SOFT_CAP_PER_DEPOSIT) / 10);
    }

    return Ok(ickb_amount);
}

The amount parameter represents the deposit unoccupied capacity, also tracked exactly by the receipt. Also given a particular deposit or a receipt cell, its accumulated rate never changes as it depends on the cell inclusion block. This means that iCKB amounts representing deposits and receipts are always deterministically calculated with the very same operations. Again, no function that converts iCKB amounts to CKB amounts exists in the code.

@mohanson does all this sound reasonable? Do you have any additional doubts?

mohanson commented 4 weeks ago

Let's assume the simplest case. There is only one ickb deposit and its capacity is 90084. A hacker starts with the following steps:

  1. use 90083 ckb to mint (90083 - 82) * 10000000000000000 // 11509953685250771 == 78194 ickb
  2. use 78194 ickb to withdraw the 90084 dao deposit, due to (90084 - 82) * 10000000000000000 // 11509953685250771 == 78194

Now, there is still only one dao deposit, but its capacity is 90083.

This attack can continue until the hacker has emptied all of ickb's dao deposits.

phroi commented 4 weeks ago

Please, let me make a few general considerations on your comment:

Let's make an example.

The iCKB pool contains only one deposit. For simplicity we can assume it was deposited in the genesis block (so its AR is GenesisAR=10000000000000000) and the capacity was exactly 100,082 CKB, while now the CurrentAR=11509953685250771.

The iCKB value of this deposit can be calculated with:

> ((100082n - 82n) * 100000000n) * 10000000000000000n / 10000000000000000n
10000000000000n

The iCKB value of this deposit is of exactly 100,000 iCKB.

Ok, now let's revisit your comment using these simple rules. Additionally for now we can assume that the current AR is always CurrentAR, so time has stopped. Also for simplicity, withdrawal is a one step process, no wait time.

There is only one ickb deposit and its capacity is 100085.

The iCKB pool contains only one deposit of capacity 100085 CKB and (it's not specified its depositAR so I assume) DepositAR=CURRENT_AR.

The iCKB value of this deposit can be calculated with:

> ((100085n - 82n) * 100000000n) * 10000000000000000n / 11509953685250771n
8688392910576n

The iCKB value of this deposit is of exactly 86,883.92910576 iCKB.

use 86883 ickb to withdraw the 100085 dao deposit, due to (100085 - 82) * 10000000000000000 / 11509953685250771 == 86883

Invalid transaction, amount mismatch, you are missing 0.92910576 iCKB to be able to withdraw this deposit. Wanna try with a different value?

This attack can continue until the hacker has emptied all of ickb's dao deposits.

Well, keep in mind in this world where time stopped, exactly one iCKB deposit exists and exactly 86,883.92910576 iCKB are in circulation. Wanna add more deposits?

Even if the truncation errors now are less than 1 satoshi, I don't mind keep going with this issue as the iCKB protocol is 100% exact up to the last satoshi. Or more generally, except for the case of burning iCKB, it's always possible withdraw all iCKB deposits and no mismatch ever exists between the accounting of deposits and the minted iCKB (xUDT + Receipts). Maybe for you this claim is easier to falsify @mohanson :hugs:

mohanson commented 4 weeks ago

Thanks for reminding me, I overlooked the fact that 1 CKB = 10^8 satoshi. I admit that the attack cannot happen in the real world.

phroi commented 4 weeks ago

No worries, I see value in the idea that division truncation could lead to errors. Then again as per first reply, given the deterministic calculations, I cannot see how it could possibly be exploited. Feel free to reopen the issue once you get the right inspiration!! 🤗

Thank you again @mohanson for taking your time to understand and review the iCKB Scripts 🙏

phroi commented 3 weeks ago

PS: I like to point out that even without the 1 CKB = 10^8 satoshi, this attack would not succeed.

Let's revisit your comment using these assumptions:

There is only one ickb deposit and its capacity is 90084 [CKB, deposited at CurrentAR].

:+1:

use 90083 ckb to mint (90083 - 82) * 10000000000000000 // 11509953685250771 == 78194 ickb

:+1: (Correct under the current assumptions)

use 78194 ickb to withdraw the 90084 dao deposit, due to (90084 - 82) * 10000000000000000 // 11509953685250771 == 78194

:+1: (Correct under the current assumptions)

Now, there is still only one dao deposit, but its capacity is 90083.

:+1:

This attack can continue until the hacker has emptied all of ickb's dao deposits.

Nope, this is a rushed conclusion, let's analyze why. Under our particular assumptions, we agreed on this iCKB Value function: ickb_value(x) = (x - 82) * 10000000000000000 // 11509953685250771

This function creates a partition of the integer domain, it basically create buckets of x mapped to the same y, as you showed.

But that's the thing, even with such a rough approximation these buckets are finite and pretty small actually. Few x exists that map to the same bucket y.

Your attack is able to freely move inside the same bucket (as intended by the protocol), but it'll never be able to escape the bucket y and reach bucket y-1.

For example, let's examine bucket 78194 iCKB, only two values are contained: 90083 CKB and 90084 CKB (under our assumptions 1 CKB is the smallest unit). Already ickb_value(90085 CKB) = 78195 iCKB and ickb_value(90082 CKB) = 78193 iCKB are in different buckets.

The attacker, after using exactly the steps you proposed, may try to forcefully move all deposits to the next lower big bucket, trying to exploit the same mechanism once again. But that's the thing:

From this point on the attacker has to wait for other users to make inefficient deposits, so that he can apply your trick, move to the min x of the same bucket and grab some free CKB. That said, he'll never be able to empty all of ickb's dao deposits, as shown.

Once we turn back on the fact 1 CKB = 10^8 satoshi, the scale of 1 satoshi gains is not even enough for the attacker to try to get free satoshis due to he bigger cost in form of tx fees.

Love & Peace, Phroi

mohanson commented 3 weeks ago

Your attack is able to freely move inside the same bucket (as intended by the protocol), but it'll never be able to escape the bucket y and reach bucket y-1.

Note that ar is a changing value; the attacker can wait for ar to grow, until 90082 and 90083 are mapped to the same bucket (e.g., both are mapped to 78193 ickb).

This is why I said the attack can continue.

phroi commented 3 weeks ago

Nope, this is the reason why I need to fully rebuke this issue.

Agreed, let's change the assumptions to the followings:

The only thing that changes in my previous comment is the iCKB Value function: ickb_value(x, x_deposit_AR) = (x - 82) * 10000000000000000 // x_deposit_AR

SideNote: x_deposit_AR does NOT depend on the currentAR, but on the AR of the deposit inclusion block, so it's a past AR that does not change as time moves on. This means that a iCKB deposit from creation until withdrawal will always have the same iCKB value.

Note that ar is a changing value

:+1: (under current assumptions)

the attacker can wait for ar to grow

Since the attacker cannot change the x_deposit_AR, he has to create a new deposit to be able to use current_AR (or wait for other user to make such a deposit). If he makes the deposit, the protocol disburse to him equal-or-under-valued iCKB, never over-valued iCKB. No gain for him here.

until 90082 and 90083 are mapped to the same bucket (e.g., both are mapped to 78193 ickb).

You are imprecise under the new assumptions.

deposit_A: 90083 CKB and which deposit_AR? I assume A_deposit_AR=11509953685250771 (oldest)

deposit_B: 90082 CKB and which deposit_AR? Let's keep this AR a variable: B_deposit_AR (newest)

In your reasoning, attacker is trying to create a Deposit B to snatch up Deposit A.

For this to happen:

ickb_value(deposit_A) <= ickb_value(deposit_B) (otherwise tx validation fails due to Amount Mismatch as before)

So the following needs to happen for this attack to succeed:

ickb_value(90083 CKB, A_deposit_AR) <= ickb_value(90082 CKB, B_deposit_AR)

Plugging in the numbers:

(90083 - 82) * 10000000000000000 // 11509953685250771 <= (90082 - 82) * 10000000000000000 // B_deposit_AR

Not a single B_deposit_AR exists that satisfies this because B_deposit_AR >= 11509953685250771 as deposit_A is older that deposit_B and AR is non-decreasing.

@mohanson does all this sound reasonable?

phroi commented 1 week ago

@msjyryxdzzj @jlguochn when you fully understand the iCKB proposal and Scripts, feel free to evaluate this hypothetical attack vector