Closed mohanson closed 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?
Let's assume the simplest case. There is only one ickb deposit and its capacity is 90084. A hacker starts with the following steps:
(90083 - 82) * 10000000000000000 // 11509953685250771 == 78194
ickb(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.
Please, let me make a few general considerations on your comment:
1 CKB = 10^8 satoshi
(same with iCKB), as you are aware this changes considerably the integer division truncation error.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:
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.
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 🙏
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:
1 CKB
and 1 iCKB
are the minimal units, this would increase again the hypothetical error margin.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:
x
of the bucket, for the same iCKB he is first losing some CKB, that later on he'll recoup with the previous trick you described. 0 CKB
gained.x
of the bucket, then he's not able to apply the previous trick. 0 CKB
gained.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
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.
Nope, this is the reason why I need to fully rebuke this issue.
Agreed, let's change the assumptions to the followings:
1 CKB
and 1 iCKB
are the minimal units, this would increase again the hypothetical error margin.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?
@msjyryxdzzj @jlguochn when you fully understand the iCKB proposal and Scripts, feel free to evaluate this hypothetical attack vector
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.
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).