Ethreal is a puzzle in zkctf hosted by scalebit. At that time, I understood the code of this puzzle quickly, but I couldn't find its vulnerability. I was stuck and couldn't solve it in the end, so I will give a comprehensive summary here.
The original code of this puzzle is on github, which contains Solidity and Golang codes. Solidity is the topic, and we need to call the register
and mint
of the contract. golang is auxiliary code that helps us generate proofs and call contracts. I am not familiar with the Go language. I can only understand the logic, but rewriting it is more troublesome.
The problem is that if we follow the default settings in Golang, we can only mint 9. To finally solve the problem, we need to mint 20.
Let’s take a look at the contract logic first:
Implemented functions related to BN254 elliptic curves G1 and G2.
PRIME_Q
: base field
's Modulus
.
G1Point
:
G2Point
:
negate()
:
plus()
:
mulScalar()
:
pairing()
:
Given srs
, the prove key (in
PRIME_Q
:
PRIME_R
: scalar field
's Modulus
.
bn254_b_coeff
:
SRS_G1_X[17]
, SRS_G1_Y[17]
,
SRS_G2_X_0[2]
, SRS_G2_X_1[2]
,
SRS_G2_Y_0[2]
, SRS_G2_Y_1[2]
,
SRS:
g1Check
: verify(_commitment,_proof,_index,_value)
:
_commitment
(_proof
(_index
(_value
(commitmentMinusA
: negProof
: indexMulProof
: verifySoulBox
:
_commitment
(_proof
(commitmentMinusA
: negProof
: indexMulProof
: fr_inverse
: PRIME_R
fr_pow
: fr_div
: fr_mul_add
: getBarycentricWeights
and evaluateBarycentricPolynomial
, use for evaluating the poly defined in xValues
, yValues
, and return
struct DepositInfo {
bool registered;
uint256 nonce;
Pairing.G1Point commitment;
Pairing.G1Point soulBox;
uint256[] collectedY;
bool escaped0;
bool escaped1;
}
getCollectedY
: return DepositInfo.collectedY
register
: _commitment
(_proof
(_soulBox
(registered
is falseverifySoulBox
(DepositInfo
with getNonce
: noncemint
: _proof
(_value
(registered
is trueKZGVerifier.verify
with (nonce
, collectedY.push(value)
[(1, v1), (2, v2), (3, v3), ....]
S.x != DepositInfo.soulBox.x
Why can't we mint gems over polynomial S.x ==DepositInfo.soulBox.x
, that is, we can recover the correct
Attack methods:
KZGVerifier.verify
: value
), proof
) is the input we can decide on. KZGVerifier.verifySoulBox
: _commitment
), _proof
), _soulBox
), we can all decide. verifySoulBox
, because Based on the second attack method, we use Rust and Foundry to reproduce the entire attack process.
Suppose halo2curve
(the bn254 curve in the previous article). It does not have a native implementation of polynomial commitment, so it needs to be written manually. If you use arkworks, it may be more convenient:
f.len() = n
-> f.degree = n-1
Let’s use sage to verify it and pass the verification:
r = 21888242871839275222246405745257275088548364400416034343698204186575808495617
Fr = GF(r)
R.<x> = PolynomialRing(Fr, 'x')
points = [(1,3),(3,7),(19,23), (20, 24)]
f = R.lagrange_polynomial(points)
# 19520216596230932581243139626618330122828219713821317177859509581595384769483*x^3 + 119041320881932900331515540018065882060 52619235313983590432356662874562515160*x^2 + 5056056101974569422682649280337206818699768384891423137871807399823066874721*x + 7 296080957279758407415468581752425029516121466805344781232734728858602831873
p = (f-23)/(x-19)
# 19520216596230932581243139626618330122828219713821317177859509581595384769483*x^2 + 106881185953133302985823092381811840344 66598990846426126893567541398128709848*x + 11136123566374368095528873098464227676629869607229210455565753007205235901280
Fr(19520216596230932581243139626618330122828219713821317177859509581595384769483)*Fr(19) + Fr(119041320881932900331515540018065 88206052619235313983590432356662874562515160)
# 10688118595313330298582309238181184034466598990846426126893567541398128709848
Fr(19520216596230932581243139626618330122828219713821317177859509581595384769483)*Fr(19)*Fr(19) + Fr(119041320881932900331515540 01806588206052619235313983590432356662874562515160)*Fr(19) + 505605610197456942268264928033720681869976838489142313787180739982 3066874721
# 11136123566374368095528873098464227676629869607229210455565753007205235901280
Then use rust to generate proofs etc:
// f(x) = a_0 + a_1 x + a_2 x^2 + ...
// y = f(x)
// h(x) = (f(x) - y_0) / (x - x_0)
// H = h(\tau) * G
pub fn open(f: &[Fr], x: Fr, srs: &[G1Affine]) -> (Fr, G1Affine) {
let mut y = Fr::zero();
let mut x_pow = Fr::one();
for a in f.iter() {
y += x_pow * a;
x_pow *= x;
}
let mut h = vec![Fr::zero(); f.len() - 1];
let len = h.len();
for (i, a) in f.iter().skip(1).rev().enumerate() {
if i == 0 {
h[len - 1 - i] = *a;
} else {
h[len - 1 - i] = h[len - i] * x + a;
}
}
let proof = h
.iter()
.zip(srs.iter())
.fold(G1Affine::identity(), |acc, (a_i, g_i)| {
(acc + g_i * a_i).into()
});
(y,proof)
}
pub fn commit(f: &[Fr], srs: &[G1Affine]) -> G1Affine {
f.iter()
.zip(srs.iter())
.fold(G1Affine::identity(), |acc, (a_i, g_i)| {
(acc + g_i * a_i).into()
})
}
// e(C- yG1, G2) = e(H, \tau G2 - xG2)
pub fn verify(commit: G1Affine, y: Fr, proof: G1Affine, x: Fr, tau_g2: G2Affine) -> bool {
let y_g1: G1Affine = (G1Affine::generator() * y).into();
let lhs = Bn256::pairing(&(commit - y_g1).into(), &G2Affine::generator());
let x_g2: G2Affine = (G2Affine::generator() * x).into();
let rhs = Bn256::pairing(&proof, &(tau_g2 - x_g2).into());
lhs == rhs
}
We first randomly generate a polynomial of degree 16, then generate the corresponding commitment and proof according to normal conditions and hack conditions, and print them out. Then use these data to create test in forge:
function testNormal() external {
mint.register(
Pairing.G1Point(
0x0fa67f58acfdcf1b7b8529868df096fa97ce8a4147546b6c3f15a299b7484f76,
0x09b521d1fc0fb731d9522de51b8a1ea9aaa67ab863b6c3db9da0e2785af1d1a2
),
Pairing.G1Point(
0x2b23e467ea98dcba7e033afc15697dfa4db2328c3f9d4fc112900687e9192fba,
0x0961852c4759acb47308d23ddd8b0f464848c54e5ab98f50f2a62cefc144c555
),
Pairing.G1Point(
0x2bc5228f3e5e11ba4536ae9315811c424d9f7e5bbc3c3ab4124c9bf8492c9c16,
0x1fac4e67bdcfd2b11d889fda0c9a99b6ceefbbe3acdcf7a2e418a6a68b7219ba
)
);
(
Pairing.G1Point[20] memory proofs,
uint256[20] memory values
) = getData();
for (uint i = 0; i < 16; i++) {
mint.mint(proofs[i], values[i]);
}
vm.expectRevert("Soul dispersed");
mint.mint(proofs[16], values[16]);
assertFalse(mint.isSovled());
}
function testHack() external {
mint.register(
Pairing.G1Point(
0x0fa67f58acfdcf1b7b8529868df096fa97ce8a4147546b6c3f15a299b7484f76,
0x09b521d1fc0fb731d9522de51b8a1ea9aaa67ab863b6c3db9da0e2785af1d1a2
),
Pairing.G1Point(
0x0000000000000000000000000000000000000000000000000000000000000001,
0x0000000000000000000000000000000000000000000000000000000000000002
),
Pairing.G1Point(
0x1820f5d55d603c14b534fff86db54c5fecfde13464ba660be352c3b6a87451c3,
0x04fefd0113a6a5290731bacf1d9be2463c8d9290d34973e93ea596555fce41a6
)
);
(
Pairing.G1Point[20] memory proofs,
uint256[20] memory values
) = getData();
for (uint i = 0; i < 20; i++) {
mint.mint(proofs[i], values[i]);
}
assertEq(mint.isSovled(), true);
}
Finally, the hack is successful:
forge test -vvv
[⠢] Compiling...
[⠒] Compiling 2 files with 0.8.19
[⠢] Solc 0.8.19 finished in 1.81s
Compiler run successful!
Running 3 tests for test/MintNFT.t.sol:MintTest
[PASS] testEvaluateBarycentricPolynomial() (gas: 84270)
Logs:
18569430475105882587588266137607568536673111973893317399460219858819262702947
18569430475105882587588266137607568536673111973893317399460219858819262702947
80084422859880547211683076133703299733277748156566366325829078699459944778998
80084422859880547211683076133703299733277748156566366325829078699459944778998
[PASS] testHack() (gas: 6325794)
[PASS] testNormal() (gas: 4965218)
Test result: ok. 3 passed; 0 failed; 0 skipped; finished in 104.51ms
Ran 1 test suites: 3 tests passed, 0 failed, 0 skipped (3 total tests)
The complete source code is at https://github.com/flyq/Ethereal
Ethreal 是一个 scalebit 主办的 zkctf 里面的一个 puzzle。这个题目当时我比较快的看懂了它的代码,但是却找不到它的漏洞,一直被卡着,最后也没解出来,因此在这里全面的总结一下。
这道题目的原题在 github 上,里面包含Solidity和Golang的代码。其中Solidity是题目,我们需要调用合约的 register 和 mint。golang 是辅助代码,帮助我们生成证明,调用合约。
问题是如果按照 Golang 里面的默认设置,我们只能 mint 9个,要最终解决题目,我们需要mint 20个。
先看一下合约逻辑:
实现了 BN254 椭圆曲线 G1 和 G2 相关的函数。
PRIME_Q
: base field
's Modulus
.
G1Point
:
G2Point
:
negate()
:
plus()
:
mulScalar()
:
pairing()
:
给出了 srs,其中 prove key(G_1)有 17 个元素,verify key(G_2)有 2 个元素。
PRIME_Q
:
PRIME_R
: scalar field
's Modulus
.
bn254_b_coeff
:
SRS_G1_X[17]
, SRS_G1_Y[17]
,
SRS_G2_X_0[2]
, SRS_G2_X_1[2]
,
SRS_G2_Y_0[2]
, SRS_G2_Y_1[2]
,
SRS:
g1Check
: verify(_commitment,_proof,_index,_value)
:
_commitment
(_proof
(_index
(_value
(commitmentMinusA
: negProof
: indexMulProof
: verifySoulBox
:
_commitment
(_proof
(commitmentMinusA
: negProof
: indexMulProof
: fr_inverse
: PRIME_R
fr_pow
: fr_div
: fr_mul_add
: getBarycentricWeights
and evaluateBarycentricPolynomial
, use for evaluating the poly defined in xValues
, yValues
, and return
struct DepositInfo {
bool registered;
uint256 nonce;
Pairing.G1Point commitment;
Pairing.G1Point soulBox;
uint256[] collectedY;
bool escaped0;
bool escaped1;
}
getCollectedY
: return DepositInfo.collectedY
register
: _commitment
(_proof
(_soulBox
(registered
is falseverifySoulBox
(DepositInfo
with getNonce
: noncemint
: _proof
(_value
(registered
is trueKZGVerifier.verify
with (nonce
, collectedY.push(value)
[(1, v1), (2, v2), (3, v3), ....]
S.x != DepositInfo.soulBox.x
为什么我们无法 mint 超过多项式 S.x ==DepositInfo.soulBox.x
,即我们能够恢复出正确的
攻击手段:
KZGVerifier.verify
:value
),proof
) 是我们能够决定的输入。KZGVerifier.verifySoulBox
: _commitment
),_proof
),_soulBox
),我们都可以决定。 verifySoulBox
的验证,因为 我们基于第二个攻击手段,使用 Rust 和 Foundry 来复现整个攻击过程。
假设 halo2curve
里面的 bn256 mod (即前文中的 bn254 曲线),它并没有原生的多项式承诺的实现,因此需要手动写,如果使用 arkworks,可能方便点:
f.len() = n
-> f.degree = n-1
我们用 sage 验证一下,并通过验证:
r = 21888242871839275222246405745257275088548364400416034343698204186575808495617
Fr = GF(r)
R.<x> = PolynomialRing(Fr, 'x')
points = [(1,3),(3,7),(19,23), (20, 24)]
f = R.lagrange_polynomial(points)
# 19520216596230932581243139626618330122828219713821317177859509581595384769483*x^3 + 11904132088193290033151554001806588206052619235313983590432356662874562515160*x^2 + 5056056101974569422682649280337206818699768384891423137871807399823066874721*x + 7296080957279758407415468581752425029516121466805344781232734728858602831873
p = (f-23)/(x-19)
# 19520216596230932581243139626618330122828219713821317177859509581595384769483*x^2 + 10688118595313330298582309238181184034466598990846426126893567541398128709848*x + 11136123566374368095528873098464227676629869607229210455565753007205235901280
Fr(19520216596230932581243139626618330122828219713821317177859509581595384769483)*Fr(19) + Fr(11904132088193290033151554001806588206052619235313983590432356662874562515160)
# 10688118595313330298582309238181184034466598990846426126893567541398128709848
Fr(19520216596230932581243139626618330122828219713821317177859509581595384769483)*Fr(19)*Fr(19) + Fr(11904132088193290033151554001806588206052619235313983590432356662874562515160)*Fr(19) + 5056056101974569422682649280337206818699768384891423137871807399823066874721
# 11136123566374368095528873098464227676629869607229210455565753007205235901280
然后使用 rust 来生成证明等:
// f(x) = a_0 + a_1 x + a_2 x^2 + ...
// y = f(x)
// h(x) = (f(x) - y_0) / (x - x_0)
// H = h(\tau) * G
pub fn open(f: &[Fr], x: Fr, srs: &[G1Affine]) -> (Fr, G1Affine) {
let mut y = Fr::zero();
let mut x_pow = Fr::one();
for a in f.iter() {
y += x_pow * a;
x_pow *= x;
}
let mut h = vec![Fr::zero(); f.len() - 1];
let len = h.len();
for (i, a) in f.iter().skip(1).rev().enumerate() {
if i == 0 {
h[len - 1 - i] = *a;
} else {
h[len - 1 - i] = h[len - i] * x + a;
}
}
let proof = h
.iter()
.zip(srs.iter())
.fold(G1Affine::identity(), |acc, (a_i, g_i)| {
(acc + g_i * a_i).into()
});
(y, proof)
}
pub fn commit(f: &[Fr], srs: &[G1Affine]) -> G1Affine {
f.iter()
.zip(srs.iter())
.fold(G1Affine::identity(), |acc, (a_i, g_i)| {
(acc + g_i * a_i).into()
})
}
// e(C- yG1, G2) = e(H, \tau G2 - xG2)
pub fn verify(commit: G1Affine, y: Fr, proof: G1Affine, x: Fr, tau_g2: G2Affine) -> bool {
let y_g1: G1Affine = (G1Affine::generator() * y).into();
let lhs = Bn256::pairing(&(commit - y_g1).into(), &G2Affine::generator());
let x_g2: G2Affine = (G2Affine::generator() * x).into();
let rhs = Bn256::pairing(&proof, &(tau_g2 - x_g2).into());
lhs == rhs
}
我们先随机生成 16 次多项式,然后按照正常情况以及hack情况生成对应的commitment 以及 proof 等,并打印出来。然后在 forge 里面使用这些数据建立 test:
function testNormal() external {
mint.register(
Pairing.G1Point(
0x0fa67f58acfdcf1b7b8529868df096fa97ce8a4147546b6c3f15a299b7484f76,
0x09b521d1fc0fb731d9522de51b8a1ea9aaa67ab863b6c3db9da0e2785af1d1a2
),
Pairing.G1Point(
0x2b23e467ea98dcba7e033afc15697dfa4db2328c3f9d4fc112900687e9192fba,
0x0961852c4759acb47308d23ddd8b0f464848c54e5ab98f50f2a62cefc144c555
),
Pairing.G1Point(
0x2bc5228f3e5e11ba4536ae9315811c424d9f7e5bbc3c3ab4124c9bf8492c9c16,
0x1fac4e67bdcfd2b11d889fda0c9a99b6ceefbbe3acdcf7a2e418a6a68b7219ba
)
);
(
Pairing.G1Point[20] memory proofs,
uint256[20] memory values
) = getData();
for (uint i = 0; i < 16; i++) {
mint.mint(proofs[i], values[i]);
}
vm.expectRevert("Soul dispersed");
mint.mint(proofs[16], values[16]);
assertFalse(mint.isSovled());
}
function testHack() external {
mint.register(
Pairing.G1Point(
0x0fa67f58acfdcf1b7b8529868df096fa97ce8a4147546b6c3f15a299b7484f76,
0x09b521d1fc0fb731d9522de51b8a1ea9aaa67ab863b6c3db9da0e2785af1d1a2
),
Pairing.G1Point(
0x0000000000000000000000000000000000000000000000000000000000000001,
0x0000000000000000000000000000000000000000000000000000000000000002
),
Pairing.G1Point(
0x1820f5d55d603c14b534fff86db54c5fecfde13464ba660be352c3b6a87451c3,
0x04fefd0113a6a5290731bacf1d9be2463c8d9290d34973e93ea596555fce41a6
)
);
(
Pairing.G1Point[20] memory proofs,
uint256[20] memory values
) = getData();
for (uint i = 0; i < 20; i++) {
mint.mint(proofs[i], values[i]);
}
assertEq(mint.isSovled(), true);
}
最后,成功hack成功:
forge test -vvv
[⠢] Compiling...
[⠒] Compiling 2 files with 0.8.19
[⠢] Solc 0.8.19 finished in 1.81s
Compiler run successful!
Running 3 tests for test/MintNFT.t.sol:MintTest
[PASS] testEvaluateBarycentricPolynomial() (gas: 84270)
Logs:
18569430475105882587588266137607568536673111973893317399460219858819262702947
18569430475105882587588266137607568536673111973893317399460219858819262702947
80084422859880547211683076133703299733277748156566366325829078699459944778998
80084422859880547211683076133703299733277748156566366325829078699459944778998
[PASS] testHack() (gas: 6325794)
[PASS] testNormal() (gas: 4965218)
Test result: ok. 3 passed; 0 failed; 0 skipped; finished in 104.51ms
Ran 1 test suites: 3 tests passed, 0 failed, 0 skipped (3 total tests)