Members of the Zellic cryptography team (Malte Leip and Avi Weinstock) participated in the most recent ZK Hack competition, ZK Hack V↗, consisting of three puzzles overall. We were happy to win the second and third puzzle, while just missing a top three placement on the first puzzle with fourth place.
As with last year’s ZK Hack IV↗ contest, the puzzles that needed to be solved for ZK Hack V consisted of small cryptographic applications. They all combined the kind of cryptographic primitives that are typically used in projects surrounding ZK, but in each puzzle some kind of vulnerability was introduced as well. The task was then to understand the provided code, find the vulnerability, and implement a solution leveraging the found vulnerability. Compared to ZK Hack IV, this edition offered greater variety in languages and frameworks used, with the puzzles being based around Rust plus halo2↗, pure Rust, and Noir↗, respectively.
In this post, we summarize the puzzles and their solutions. Regarding solutions, we focus on describing how one can solve these challenges quickly, without needing to be familiar with the full background. They are thus written by taking the perspective of someone who is familiar with the general cryptographic principles but unfamiliar with the precise protocol the challenge is built around. Each puzzle comes with multiple other write-ups on the ZK Hack V website↗, which in some cases contain more information about the various protocols.
Puzzle 1: Zeitgeist
This challenge was written in Rust using the halo2 framework and required going down to the level of the PLONK proofs to solve. In the end, there were some obstacles that delayed us that were not directly related to the challenge itself but to parsing and extracting data.
Understanding the Task
The puzzle description describes the challenge as follows:
A few years ago, Bob signed up to SuperCoolAirdrop™. The process was simple: provide a hash of your private key to register and when your airdrop is ready you’ll receive a notification. Today, Bob finally received a notification from SuperCoolAirdrop™. It’s time to claim his airdrop!
The process is simple, he just needs to give a proof of knowledge that he knows the private key associated with the commitment he made years ago. To prevent replay attacks, SuperCoolAirdrop™ implemented a nullifier system: on top of proving knowledge of the private key, users must create a secret nonce and produce a public nullifier. Once the SuperCoolAirdrop™ server verifies the proof and logs the nullifier, the proof cannot be re-used.
So Bob picks a random nonce, his favorite proof system and sends in a proof. The proof gets rejected… weird. He samples a new nonce and tries again. And again. And again. Still, his proofs get rejected.
Suddenly it dawns on him. Is SuperCoolAirdrop™ legit? Is this an attempt to steal his private key?
The challenge comes with 64 commitment, proof, and nullifier files. The commitments are all identical.
The main
function in main.rs contains the spot in which we are to add our attack, and is given as follows:
pub fn main() {
welcome();
puzzle(PUZZLE_DESCRIPTION);
/* This is how the serialized proofs, nullifiers and commitments were created. */
// serialize();
/* Implement your attack here, to find the index of the encrypted message */
let secret = Fr::from(0u64);
let secret_commitment = poseidon_base::primitives::Hash::<
_,
P128Pow5T3Compact<Fr>,
ConstantLength<2>,
WIDTH,
RATE,
>::init()
.hash([secret, Fr::from(0u64)]);
for i in 0..64 {
let (_, _, commitment) = from_serialized(i);
assert_eq!(secret_commitment, commitment);
}
/* End of attack */
}
So the commitments are all the Poseidon hash of the secret, and we have to find that secret. We can assume that the 64 proofs together with the nullifiers were produced using the same secret.
What Does the Circuit Do?
Let us check out the create_proof
function that was presumably used to create these proofs:
pub fn create_proof<
'params,
Scheme: CommitmentScheme<Scalar = halo2curves::bn256::Fr>,
P: Prover<'params, Scheme>,
E: EncodedChallenge<Scheme::Curve>,
R: RngCore,
T: TranscriptWriterBuffer<Vec<u8>, Scheme::Curve, E>,
>(
rng: R,
params: &'params Scheme::ParamsProver,
pk: &ProvingKey<Scheme::Curve>,
) -> (Vec<u8>, Fr, Fr)
where
Scheme::Scalar: Ord + WithSmallOrderMulGroup<3> + FromUniformBytes<64>,
{
let n_rng = OsRng;
let witness = Fr::from_str_vartime(
"0", /* This is just an example of how to create a proof, no luck finding it here! */
)
.unwrap();
let nonce = Fr::random(n_rng);
let msg = [witness, nonce];
let nullifier = poseidon_base::primitives::Hash::<
_,
P128Pow5T3Compact<Fr>,
ConstantLength<2>,
WIDTH,
RATE,
>::init()
.hash(msg);
let msg = [witness, Fr::from(0u64)];
let commitment = poseidon_base::primitives::Hash::<
_,
P128Pow5T3Compact<Fr>,
ConstantLength<2>,
WIDTH,
RATE,
>::init()
.hash(msg);
let circuit = MyCircuit {
witness: Value::known(witness),
nonce: Value::known(nonce),
};
let public_inputs = vec![nullifier, commitment];
let mut transcript = T::init(vec![]);
create_plonk_proof::<Scheme, P, _, _, _, _>(
params,
pk,
&[circuit],
&[&[&public_inputs.clone()]],
rng,
&mut transcript,
)
.expect("proof generation should not fail");
(transcript.finalize(), nullifier, commitment)
}
We see that the secret is a single field element and the nonce is a field element that is generated randomly. The commitment is the hash of the secret, the nullifier the hash of secret and nonce, and both commitment and nullifier are public inputs to the proof. We can guess from this that the circuit likely ensures precisely this relationship. Checking the circuit implementation confirms this assumption.
Lack of Blinding Factors
During the presentation of this puzzle, it was mentioned that this puzzle was about what happens when using proving schemes without configuring them for zero knowledge as well as relying on the proof’s small size as the obstacle for an attacker to extract secrets.
It thus makes sense to have a look at the function create_plonk_proof
called by create_proof
. Slightly confusingly, that function is actually also called create_proof
and only imported as create_plonk_proof
due to the name clash. It is found in halo2/halo2_proofs/src/plonk/prover.rs in the repository. That a copy of halo2 is part of the challenge repository instead of just a dependency can already be taken as a sign that something might be modified here. As one looks for places referencing adding zero knowledge or blinding, the following passage seems interesting:
// Add blinding factors to advice columns
for advice_values in &mut advice_values {
//for cell in &mut advice_values[unusable_rows_start..] {
//*cell = C::Scalar::random(&mut rng);
//*cell = C::Scalar::one();
//}
let idx = advice_values.len() - 1;
advice_values[idx] = Scheme::Scalar::ONE;
}
The comment suggests that blinding factors are added to advice columns at the bottom, yet only a single value 1
is added in the last row, with the code that would add random blinding values being commented out. Checking against an unmodified version of halo2, we find that this snippet is supposed to look as follows:
// Add blinding factors to advice columns
for advice in &mut advice {
for cell in &mut advice[unusable_rows_start..] {
*cell = C::Scalar::random(&mut rng);
}
}
So it appears that the process of adding rows filled with random values to the bottom of the table, intended to make the proving scheme zero knowledge, has been disabled.
Finding the Interesting Column
To make progress from here, it is highly useful to test what happens during proving and output some relevant data. To be able to recognize the secret in outputs, it will be advisable to change its value in create_proof
from 0
to something more recognizable, like 0x2a = 42
. The create_proof
requires some arguments, but luckily there is already a function shplonk
given, also in main.rs, that takes no parameters and does everything necessary to call create_proof
, returning the proof, nullifier, and commitment. We can thus add the following to the main
function to test:
let (proof, nullifier, commitment) = shplonk();
println!("nullifier = {:?}", nullifier);
println!("commitment = {:?}", commitment);
We would then like to check what the values of the columns of the table are during proof generation, to hopefully help figure out how to use the lack of blinding factors. In halo2’s create_proof
function, the advice_values
vector, which should be filled with blinding factors but is not, will be saved into some advice
variable as follows, a couple of lines below the snippet that was quoted above:
for ((column_index, advice_values), blind) in
column_indices.iter().zip(advice_values).zip(blinds)
{
advice.advice_polys[*column_index] = advice_values;
advice.advice_blinds[*column_index] = blind;
}
From this information, we can construct the following, which we place just after the big loop that is handling all the advice columns, printing out each column:
for zellic_i in 0..advice[0].advice_polys.len() {
println!("");
for zellic_j in 0..advice[0].advice_polys[zellic_i].len() {
println!("advice_polys[{}][{}] = {:?}", zellic_i, zellic_j, advice[0].advice_polys[zellic_i][zellic_j])
}
}
Running this once, we see for example that the first column consists of the secret, then what is likely the nonce, and then only zeros (except the last entry, which is 1
, as mentioned before):
advice_polys[0][0] = 0x000000000000000000000000000000000000000000000000000000000000002a
advice_polys[0][1] = 0x28d6b056ad8c883722d5d617d4b2f9062a183a6f67389c4d5c30a08be28126bf
advice_polys[0][2] = 0x0000000000000000000000000000000000000000000000000000000000000000
advice_polys[0][3] = 0x0000000000000000000000000000000000000000000000000000000000000000
advice_polys[0][4] = 0x0000000000000000000000000000000000000000000000000000000000000000
...
As the nonce is a different random value each time we run the prover, it may be interesting to check which values change and which do not. Saving the output of two runs and diffing, we see that column 0
will stay the same except at index 1
, as expected. Furthermore, columns 1
through 4
have no differences between the two runs at all, so they apparently don’t depend on the nonce. Columns 5
onwards have many differences between the two runs. Of most use to us will be columns that contain the secret. Filtering for columns that do, we find the following:
% cargo run --release | grep "0x000000000000000000000000000000000000000000000000000000000000002a"
...
advice_polys[0][0] = 0x000000000000000000000000000000000000000000000000000000000000002a
advice_polys[1][2] = 0x000000000000000000000000000000000000000000000000000000000000002a
advice_polys[1][3] = 0x000000000000000000000000000000000000000000000000000000000000002a
advice_polys[1][4] = 0x000000000000000000000000000000000000000000000000000000000000002a
advice_polys[5][2] = 0x000000000000000000000000000000000000000000000000000000000000002a
advice_polys[5][3] = 0x000000000000000000000000000000000000000000000000000000000000002a
advice_polys[5][4] = 0x000000000000000000000000000000000000000000000000000000000000002a
Column number 1
thus seems most promising: it is the same on each run, and it contains the secret in three places.
What Information Do We Have?
Presumably the lack of blinding factors mean that the proof leaks some information about the assignments (including the secret we are after) to the proof. To be able to more quickly find where in the code this happens, let us have a look at what data the proof contains. The shplonk
function returns the proof as its first argument and obtains it the same way from create_proof
in main.rs:
let (proof, nullifier, commitment) =
create_proof::<_, ProverSHPLONK<_>, _, _, Blake2bWrite<_, _, Challenge255<_>>>(
rng, ¶ms, &pk,
);
Checking in that function, we can see that the proof consists of the transcript:
let mut transcript = T::init(vec![]);
create_plonk_proof::<Scheme, P, _, _, _, _>(
params,
pk,
&[circuit],
&[&[&public_inputs.clone()]],
rng,
&mut transcript,
)
.expect("proof generation should not fail");
(transcript.finalize(), nullifier, commitment)
}
Back in the create_proof
function in halo2/halo2_proofs/src/plonk/prover.rs, where the argument that transcript
is passed to is also called transcript
, we can search for this word while skimming the code, checking where data is written to the transcript.
Polynomial Evaluations
Doing this, we find the following snippet, in which advice.advice_polys
is used (which contains the data we are after) and something is written to transcript
(which collects data we actually have):
// Compute and hash advice evals for each circuit instance
for advice in advice.iter() {
// Evaluate polynomials at omega^i x
let advice_evals: Vec<_> = meta
.advice_queries
.iter()
.map(|&(column, at)| {
eval_polynomial(
&advice.advice_polys[column.index()],
domain.rotate_omega(*x, at),
)
})
.collect();
// Hash each advice column evaluation
for eval in advice_evals.iter() {
transcript.write_scalar(*eval)?;
}
}
This snippet appears to treat advice.advice_polys[column.index()]
as a polynomial and evaluate it at some point, writing the result of the evaluation to the transcript.
To figure out what is going on, let’s change the printout we had before to only print out the column of interest and print it out just before this evaluation snippet as well, together with the point at which it is evaluated and the result:
println!("Just before evaluation:");
for zellic_j in 0..advice[0].advice_polys[1].len() {
println!("advice_polys[{}][{}] = {:?}", 1, zellic_j, advice[0].advice_polys[1][zellic_j])
}
// Compute and hash advice evals for each circuit instance
for advice in advice.iter() {
// Evaluate polynomials at omega^i x
let advice_evals: Vec<_> = meta
.advice_queries
.iter()
.map(|&(column, at)| {
let zellic_eval_point = domain.rotate_omega(*x, at);
let zellic_result = eval_polynomial(
&advice.advice_polys[column.index()],
zellic_eval_point,
);
if column.index() == 1 {
println!("Evaluation at: {:?} with result {:?}", zellic_eval_point, zellic_result);
}
zellic_result
})
.collect();
// Hash each advice column evaluation
for eval in advice_evals.iter() {
transcript.write_scalar(*eval)?;
}
}
We obtain the following output (shortened):
advice_polys[1][0] = 0x0000000000000000000000000000000000000000000000000000000000000000
advice_polys[1][1] = 0x0000000000000000000000000000000000000000000000000000000000000000
advice_polys[1][2] = 0x000000000000000000000000000000000000000000000000000000000000002a
advice_polys[1][3] = 0x000000000000000000000000000000000000000000000000000000000000002a
advice_polys[1][4] = 0x000000000000000000000000000000000000000000000000000000000000002a
advice_polys[1][5] = 0x08a62974fc6977295bf2283add4b3efb12fa85ff0cb70e26bc638bdac6d9bb3f
advice_polys[1][6] = 0x0cebe9b6b698c395b4a481c0f8766654a3885aaeee0fc03ff4271977f5caf56f
advice_polys[1][7] = 0x060da602f6da0624cd4ae8820e65eaf26d457e5609a7bb0d01a0608be81d1172
...
Just before evaluation:
advice_polys[1][0] = 0x0047b0bc6bbc49ddcc506fcc5bbb81e206c84506ccca9dc08392d26a11205c14
advice_polys[1][1] = 0x06be2f5f221c5e1f0e2a8aa8f3a7291881e5b312180d758baf42e22fb5564178
advice_polys[1][2] = 0x14e2da0bdbb6f13ea757bbc47e30931a3d437b0d668abb1d191a93664bc87f42
advice_polys[1][3] = 0x2bc488b432b21f6dd20172156de40fabb66187197f05ef774f2b54332d718d9d
advice_polys[1][4] = 0x14f880531ff79a8135dfa79591a5ccedf2228f77e0cb0eeb0be3ab518ce7c258
advice_polys[1][5] = 0x276d2d2370684496b1e47d75b683bcb4d59379a0cdeba9ec60660d3650160b8f
advice_polys[1][6] = 0x13e283c1275092e6ae3495fa7f8ab315db082c16ce763ae72807496a935bb7e4
advice_polys[1][7] = 0x2c06bd7148e6fb37f6f344cd03a615925c9d588b089c96c7cfb902417b3a4b22
...
Evaluation at: 0x2e41c57b0b55f6c28fe3400a1ef0dec2aa51f185e1156f285e78dda24c102bf5 with result 0x17fc29532cb3c0aa587c283328270d76b99f50c0f038e9e66431438bdacc8ad3
Evaluation at: 0x1b36a3314892fc220c1c3558d692f0d17cdea0359317ea9ba05f7af557592553 with result 0x08a3c5b002343a928d7a81021399b78723bd72fd3beba19d9e7bdafca4fc753d
Evaluation at: 0x0f7821cf94df9c91b51d3ce6cb7cb34c590c7997a59537cb59bb677400e86f81 with result 0x30545dd7c357e8584beb0913f43cdb5596a8e60184dd10ba69bb2fb70b70acf6
We can immediately make two observations here. The first is that advice[0].advice_polys[1]
changed between the two code locations at which we print it out, and we need to understand what happened there. The second is that there are apparently not just one but three evaluations done of column 1
. If we run the program again, we notice that the values of advice[0].advice_polys[1]
before evaluation are the same as in the previous run, but the evaluation points differ.
How is advice_polys
Modified?
Checking where advice[0].advice_polys[1]
might be changed between the two places we print it out, we find the following snippet:
// Calculate the advice polys
let advice: Vec<AdviceSingle<Scheme::Curve, Coeff>> = advice
.into_iter()
.map(
|AdviceSingle {
advice_polys,
advice_blinds,
}| {
AdviceSingle {
advice_polys: advice_polys
.into_iter()
.map(|poly| domain.lagrange_to_coeff(poly))
.collect::<Vec<_>>(),
advice_blinds,
}
},
)
.collect();
This suggests that the original data is interpreted as evaluations of a polynomial on some domain (so the polynomial is given in Lagrange form), and it is then replaced by that polynomial in coefficient form (by performing Lagrange interpolation↗).
The domain
variable is of this type
/// This structure contains precomputed constants and other details needed for
/// performing operations on an evaluation domain of size $2^k$ and an extended
/// domain of size $2^{k} * j$ with $j \neq 0$.
#[derive(Clone, Debug)]
pub struct EvaluationDomain<F: Field> {
n: u64,
k: u32,
extended_k: u32,
omega: F,
omega_inv: F,
extended_omega: F,
extended_omega_inv: F,
g_coset: F,
g_coset_inv: F,
quotient_poly_degree: u64,
ifft_divisor: F,
extended_ifft_divisor: F,
t_evaluations: Vec<F>,
barycentric_weight: F,
}
and obtained at the start of the create_proof
function as let domain = &pk.vk.domain;
. The lagrange_to_coeff
function is implemented with some inverse Fourier transformation function, so becoming certain of what exactly it is doing (for example precise points it is interpolating at) via reading the code may take a long time.
Instead, we can try to guess and check whether this guess is correct. Just from the above struct and comment, we can guess that omega
likely will be an element of the field of multiplicative degree , and exended_omega
an element of degree . Plausibly, the domain used for Lagrange interpolation could be 1, omega, omega^2, ...
or 1, extended_omega, extended_omega^2, ...
— or g_coset, g_coset*omega, g_coset*omega^2, ...
or similar (perhaps the inverse of omega
, extended_omega
, or g_coset
is used as well). We can check this by printing out evaluations of the polynomial given by advice[0].advice_polys[1]
after it has been changed and checking whether the values match the original column data.
We can first check what the constant factor in the sequence is. Is it 1
, g_coset
, g_coset_inv
, or none of them?
println!("After advice was replaced by doing lagrange_to_coeff.");
println!("Evaluation of advice[0].advice_polys[1] at 1: {:?}",
eval_polynomial(&advice[0].advice_polys[1], 1.try_into().unwrap() ,));
println!("Evaluation of advice[0].advice_polys[1] at g_coset: {:?}",
eval_polynomial(&advice[0].advice_polys[1], domain.g_coset ,));
println!("Evaluation of advice[0].advice_polys[1] at g_coset_inv: {:?}",
eval_polynomial(&advice[0].advice_polys[1], domain.g_coset_inv ,));
Trying to compile this, we get a complaint that g_coset
and g_coset_inv
are private fields. Luckily, the halo2 used is local anyway, so we just make those fields public. We get the following output:
advice_polys[1][0] = 0x0000000000000000000000000000000000000000000000000000000000000000
...
After advice was replaced by doing lagrange_to_coeff.
Evaluation of advice[0].advice_polys[1] at 1: 0x0000000000000000000000000000000000000000000000000000000000000000
Evaluation of advice[0].advice_polys[1] at g_coset: 0x06f0b7392dcdccedf941aa0bf089045e11e169c20c52fcb2bbaaf4191577ac48
Evaluation of advice[0].advice_polys[1] at g_coset_inv: 0x0fb6415c26119da23b2e5477ea4b179edcf8b149f6b9d1fa28397871b759b4e8
Just before evaluation:
advice_polys[1][0] = 0x0047b0bc6bbc49ddcc506fcc5bbb81e206c84506ccca9dc08392d26a11205c14
...
From this, it is clear that the first point of the evaluation domain is 1
. Doing a similar check for the guesses for the second point of the domain, we get this output:
Evaluation of advice[0].advice_polys[1] at ω: 0x0000000000000000000000000000000000000000000000000000000000000000
Evaluation of advice[0].advice_polys[1] at ω^-1: 0x0000000000000000000000000000000000000000000000000000000000000001
Evaluation of advice[0].advice_polys[1] at ω_ext: 0x05af6f83949440a64e85850e1123de4c443ca875bac8bfefcfec1440bec9c633
Evaluation of advice[0].advice_polys[1] at ω_ext^-1: 0x2580e0afc76e0c038ff21baefaaaf13516b26ed9552142193da3a06b5d3d8c00
From this, we see that omega
is used. The evaluation at omega^-1
gives 1
, which we know is the value of the last entry of the column. So this suggests that the length of the columns is exactly (the presumed multiplicative order of omega
, so that ).
Finally, recall that the secret was contained in the column at indexes 2
, 3
, and 4
. Checking evaluation at , we indeed see the secret:
Evaluation of advice[0].advice_polys[1] at ω^2: 0x000000000000000000000000000000000000000000000000000000000000002a
How Do We Obtain the Evaluation Points?
We can now forget about the original column. We know that three evaluations of the polynomial encoded by the updated advice[0].advice_polys[1]
, which is the same on each proving run (assuming the secret is the same), are added to the transcript and that the secret we need to extract is evaluation of that polynomial at . It seems like we should be able to collect 64 * 3 = 192
evaluations of that polynomial (three evaluations for each of the 64 proofs we are given), interpolate those evaluations to recover the polynomial, and then evaluate at to obtain the secret. For this to work, there are three things we need to check:
- The assumption that values added to the transcript with
transcript.write_scalar
really are extractable from the proof files we were given - At what points the three evalutations are done — we also must be able to calculate those from the proofs
- That the polynomial is of a degree that is smaller than 192
We can easily answer the third point,
println!("Degree of polynomial is {}", advice[0].advice_polys[1].len());
giving this:
Degree of polynomial is 64
Indeed, 192 > 64
, so no problem.
Regarding the second point, evaluation is done like this:
// Evaluate polynomials at omega^i x
let advice_evals: Vec<_> = meta
.advice_queries
.iter()
.map(|&(column, at)| {
eval_polynomial(
&advice.advice_polys[column.index()],
domain.rotate_omega(*x, at),
)
})
The comment suggests that the evaluation points are of the form . We can check that this is the case by looking at the rotate_omega
function:
/// Multiplies a value by some power of $\omega$, essentially rotating over
/// the domain.
pub fn rotate_omega(&self, value: F, rotation: Rotation) -> F {
let mut point = value;
if rotation.0 >= 0 {
point *= &self.get_omega().pow_vartime([rotation.0 as u64]);
} else {
point *= &self
.get_omega_inv()
.pow_vartime([(rotation.0 as i64).unsigned_abs()]);
}
point
}
But we do not necessarily need to do this. We should check this anyway to make sure:
println!("Evaluation at x*omega^0 is {:?}",
eval_polynomial(&advice[0].advice_polys[1], *x,));
println!("Evaluation at x*omega^1 is {:?}",
eval_polynomial(&advice[0].advice_polys[1], *x * domain.omega,));
println!("Evaluation at x*omega^2 is {:?}",
eval_polynomial(&advice[0].advice_polys[1], *x * domain.omega * domain.omega,));
println!("Evaluation at x*omega^-1 is {:?}",
eval_polynomial(&advice[0].advice_polys[1], *x * domain.omega_inv,));
// Compute and hash advice evals for each circuit instance
for advice in advice.iter() {
// Evaluate polynomials at omega^i x
let advice_evals: Vec<_> = meta
.advice_queries
.iter()
.map(|&(column, at)| {
let zellic_eval_point = domain.rotate_omega(*x, at);
let zellic_result = eval_polynomial(
&advice.advice_polys[column.index()],
zellic_eval_point,
);
if column.index() == 1 {
println!("Evaluation with rotation {:?} at {:?} with result {:?}",
at, zellic_eval_point, zellic_result);
}
zellic_result
})
.collect();
// Hash each advice column evaluation
for eval in advice_evals.iter() {
transcript.write_scalar(*eval)?;
}
}
The above yields the following output, confirming that we correctly understood the evaluation points. (In practice, one might of course need two runs — to get the rotation in the first run and then add the appropriate printout in the second.)
Evaluation at x*omega^0 is 0x02d7c8ef98e58ad739ed798b2b33c03577082d1ada060a332b1c4c48f8f96315
Evaluation at x*omega^1 is 0x21b649e53a77ca0ff9d6bdf4240dea039e99107b5fdef0b2993bda225c646f56
Evaluation at x*omega^2 is 0x120391e84c7cee33e6528e479b6af56195af206907bf8177f9a0283f92d01d93
Evaluation at x*omega^-1 is 0x17f7f5957d854c696698625c9d97072eea21736747c9d05f11cc7f3b42d73ed5
Evaluation with rotation Rotation(0) at 0x05477ffb9c023e6b01ce94a95bf2e65983f5a88c262f3427f6069b47f4336984 with result 0x02d7c8ef98e58ad739ed798b2b33c03577082d1ada060a332b1c4c48f8f96315
Evaluation with rotation Rotation(1) at 0x0d71106ed9ff2e5eaec860f164bee38f73f0c326694e166e38ca1eab0cc3fb48 with result 0x21b649e53a77ca0ff9d6bdf4240dea039e99107b5fdef0b2993bda225c646f56
Evaluation with rotation Rotation(-1) at 0x0de7b88317bffbd94211877eca0db5509299e6eb5098c9baffb394c801ceeac5 with result 0x17f7f5957d854c696698625c9d97072eea21736747c9d05f11cc7f3b42d73ed5
Now we know at which points the polynomial is evaluated: , and . We expect that omega
is part of the setup, so we should have access to that for our solution, but what about x
?
Searching for x
, we find the following line a couple of lines above the polynomial evaluation snippet:
let x: ChallengeX<_> = transcript.squeeze_challenge_scalar();
Thus, we should be able to obtain x
from the transcript.
How Can We Read the Transcript?
In the actual competition, this took quite some time due to (in retrospect) inefficient attempts. The first idea one may have is to read the transcript themselves, fast-forwarding over all the data they are not interested in to get at x
and the three relevant evaluations.
One minor problem with this plan is that it might take a little bit of time to write down the type of the transcript. In the code we are given, the transcript is instantiated in the verify_proof
function:
let mut transcript = T::init(proof);
This function is called by from_serialized
, where T
is just given as Blake2bRead<_, _, Challenge255<_>>
. We can run the verifier and print out the type of T
(using std::any::type_name
) and then fix some issues regarding missing imports and private modules occuring in the type name. This overcomes this minor problem but takes some time already.
The bigger potential problem is that we need to know how to fast-forward through the transcript correctly. The idea might be to read scalars and print them until getting an error, on a proof that we generated ourselves and know x
for, and then grep for the known value of x
to obtain the correct index. However, both points and scalars are being written to the transcript during the proving process, and we will likely need to read these different types in the correct order. This is certainly a solvable problem, and it does not necessarily require having to statically figure things out from the code. For example, we could add printouts to the transcript on proof generation, telling us each action on the transcript, and then transform this output into the sequence of commands needed to fast-forward to the right spot on reading. However, this may not be the fastest way of doing things.
Instead, we can just reuse the code already existing for verification and modify it a little. Verification ultimately ends up in the verify_proof
function in halo2/halo2_proofs/src/plonk/verifier.rs, where the transcript is actually read. We copy and paste that function (in the same file), giving a new name like return_data_for_attack
. We need the value of x
and the three evaluations from the transcript. So we first change the return value from Result<Strategy::Output, Error>
to Result<[Scheme::Scalar; 4], Error>
. Searching for let x
in the code, we find the following snippet:
// Sample x challenge, which is used to ensure the circuit is
// satisfied with high probability.
let x: ChallengeX<_> = transcript.squeeze_challenge_scalar();
And slightly below that, we find the following:
let advice_evals = (0..num_proofs)
.map(|_| -> Result<Vec<_>, _> { read_n_scalars(transcript, vk.cs.advice_queries.len()) })
.collect::<Result<Vec<_>, _>>()?;
What we need to find out is then which indexes we need to use to obtain the correct three evaluations from advice_evals
. To figure this out, we track the index in the prover and print it out:
println!("Evaluation at x*omega^0 is {:?}",
eval_polynomial(&advice[0].advice_polys[1], *x,));
println!("Evaluation at x*omega^1 is {:?}",
eval_polynomial(&advice[0].advice_polys[1], *x * domain.omega,));
println!("Evaluation at x*omega^-1 is {:?}",
eval_polynomial(&advice[0].advice_polys[1], *x * domain.omega_inv,));
// Compute and hash advice evals for each circuit instance
for advice in advice.iter() {
// Evaluate polynomials at omega^i x
let mut zellic_advice_eval_index = 0;
let advice_evals: Vec<_> = meta
.advice_queries
.iter()
.map(|&(column, at)| {
let zellic_eval_point = domain.rotate_omega(*x, at);
let zellic_result = eval_polynomial(
&advice.advice_polys[column.index()],
zellic_eval_point,
);
if column.index() == 1 {
println!("Evaluation index {} with rotation {:?} at {:?} with result {:?}",
zellic_advice_eval_index, at, zellic_eval_point, zellic_result);
}
zellic_advice_eval_index += 1;
zellic_result
})
.collect();
// Hash each advice column evaluation
for eval in advice_evals.iter() {
transcript.write_scalar(*eval)?;
}
}
This yields the following:
Evaluation at x*omega^0 is 0x13004cc8bcc62cb548db3b5c06bf79dbe0856cd7155137dd5a929f89d4d281cc
Evaluation at x*omega^1 is 0x06f04fdb8c7b00c9d59ebdb23cbab494aa6e109df4cd2f37c4ebf5b85b5cc6fb
Evaluation at x*omega^-1 is 0x145fb2b0580e9fe42f177441308e53de33a0c36f253396bca8cbe65a6b8d487b
Evaluation index 1 with rotation Rotation(0) at 0x1fbac78b430ad5b176b57cf21e68d68d09a44c5b45fb7490561a5a6773856d1a with result 0x13004cc8bcc62cb548db3b5c06bf79dbe0856cd7155137dd5a929f89d4d281cc
Evaluation index 4 with rotation Rotation(1) at 0x1eac263188b07530b1dd195ef81e2791b2388c6472ce990376f1317f0a5679d0 with result 0x06f04fdb8c7b00c9d59ebdb23cbab494aa6e109df4cd2f37c4ebf5b85b5cc6fb
Evaluation index 9 with rotation Rotation(-1) at 0x1def28d72cf461a07043902e0508a57c3f44b5a963479049f0884950f9123810 with result 0x145fb2b0580e9fe42f177441308e53de33a0c36f253396bca8cbe65a6b8d487b
So the indexes we need are 1
, 4
, and 9
.
Back in return_data_for_attack
in halo2/halo2_proofs/src/plonk/verifier.rs, we can now add the lines below the snippet in which advice_evals
is assigned as follows:
let advice_evals = (0..num_proofs)
.map(|_| -> Result<Vec<_>, _> { read_n_scalars(transcript, vk.cs.advice_queries.len()) })
.collect::<Result<Vec<_>, _>>()?;
return Ok([*x, advice_evals[0][1], advice_evals[0][4], advice_evals[0][9]]);
As the compiler complains about returns with the wrong type below this return
, we delete the rest code below the inserted snippet.
The verify_proof
function in halo2/halo2_proofs/src/plonk/verifier.rs is called by this function in src/main.rs (where it is called verify_plonk_proof
):
pub fn verify_proof<
// ...
>(
params_verifier: &'params Scheme::ParamsVerifier,
vk: &VerifyingKey<Scheme::Curve>,
proof: &'a [u8],
nullifier: Fr,
commitment: Fr,
) where
Scheme::Scalar: Ord + WithSmallOrderMulGroup<3> + FromUniformBytes<64>,
{
// ...
let strategy = verify_plonk_proof(
params_verifier,
vk,
strategy,
&[&[&pubinputs[..]]],
&mut transcript,
)
.unwrap();
assert!(strategy.finalize());
}
We can copy and paste this, give it a new name (perhaps return_data_for_attack_wrapper
), make the return type [Scheme::Scalar; 4]
, and replace
let strategy = verify_plonk_proof(
params_verifier,
vk,
strategy,
&[&[&pubinputs[..]]],
&mut transcript,
)
.unwrap();
assert!(strategy.finalize());
with
return_data_for_attack(
params_verifier,
vk,
strategy,
&[&[&pubinputs[..]]],
&mut transcript,
)
.unwrap()
Now, this function still needs some parameters as arguments, so we finally copy this function that deserializes a proof and calls the verifier
fn from_serialized(i: usize) -> (Vec<u8>, Fr, Fr) {
use halo2_proofs::poly::kzg::commitment::{KZGCommitmentScheme, ParamsKZG};
use halo2_proofs::poly::kzg::multiopen::VerifierSHPLONK;
use halo2_proofs::poly::kzg::strategy::AccumulatorStrategy;
use halo2curves::bn256::Bn256;
let setup_rng = ChaCha20Rng::from_seed([1u8; 32]);
let params = ParamsKZG::<Bn256>::setup(K, setup_rng);
let pk = keygen::<KZGCommitmentScheme<_>>(¶ms);
let (proofs, nullifiers, commitments) = deserialize();
let proof = proofs[i].clone();
let nullifier = nullifiers[i];
let commitment = commitments[i];
let verifier_params = params.verifier_params();
verify_proof::<
_,
VerifierSHPLONK<_>,
_,
Blake2bRead<_, _, Challenge255<_>>,
AccumulatorStrategy<_>,
>(
verifier_params,
pk.get_vk(),
&proof[..],
nullifier,
commitment,
);
(proof, nullifier, commitment)
}
to create the new function get_attack_data_from_serialized
that returns [Fr; 4]
instead — and where the verify_proof
call and return line is replaced by the following.
return_data_for_attack_wrapper::<
_,
VerifierSHPLONK<_>,
_,
Blake2bRead<_, _, Challenge255<_>>,
AccumulatorStrategy<_>,
>(
verifier_params,
pk.get_vk(),
&proof[..],
nullifier,
commitment,
)
How Do We Obtain Omega?
Finally, we also need the value of omega
. In the prover, we were able to access the value as domain.omega
, and searching for let domain
, we find the line let domain = &pk.vk.domain;
. Here, pk
is an argument of type &ProvingKey<Scheme::Curve>
passed to the create_proof
function. In main.rs, we can find a proving key being generated, also, for example, in from_serialized
we just considered. We can use this code to create a function that obtains the domain for us:
fn get_domain() -> EvaluationDomain<Bn256Fr> {
use halo2_proofs::poly::kzg::commitment::{KZGCommitmentScheme, ParamsKZG};
use halo2_proofs::poly::kzg::multiopen::VerifierSHPLONK;
use halo2_proofs::poly::kzg::strategy::AccumulatorStrategy;
use halo2curves::bn256::Bn256;
let setup_rng = ChaCha20Rng::from_seed([1u8; 32]);
let params = ParamsKZG::<Bn256>::setup(K, setup_rng);
let pk = keygen::<KZGCommitmentScheme<_>>(¶ms);
return pk.vk.domain
}
Trying to compile this gives some errors at first about private fields, but these can be fixed by editing the relevant struct to make the offending field public. The return type here was obtained by trying to compile with a different one and then copying and pasting the type that the compiler claims is being supplied.
The Solution
We can now finish the solution in the main
function. There is no need to implement polynomial interpolation, as halo2 already comes with the relevant functions:
let omega = get_domain().omega;
let omega_inv = get_domain().omega_inv;
let mut points = vec![];
let mut values = vec![];
for i in 0..64 {
println!("Extracting from proof {}", i);
let data = get_attack_data_from_serialized(i);
let x = data[0];
let atx = data[1];
let atxomega = data[2];
let atxomageinv = data[3];
points.push(x);
values.push(atx);
points.push(x*omega);
values.push(atxomega);
points.push(x*omega_inv);
values.push(atxomageinv);
}
println!("Collected values...");
let coeffs = lagrange_interpolate(&points, &values);
println!("Got polynomial coefficients");
let secret = eval_polynomial(&coeffs, omega*omega*omega);
println!("Recovered secret: {:?}", secret);
With this, the challenge is solved, with the recovered secret being 0x066ab256379d1a0d7bd1dd54cf3f4171edbf5ca3976e8956fe0ddd10af67418d
!
Puzzle 2: Don’t Look Up
This puzzle’s description tells us that it consists of an implementation in Rust of ProtoStar↗, a lookup protocol that is a variant of LogUp↗, instantiated over a finite field , with a 16-bit prime. Our task is to make the protocol accept a “proof” that lies in the range .
Understanding the Task
The puzzle comes with three source files: lib.rs, which appears to contain implementations of the necessary basics, such as arithmetic operations for finite fields; protocol.rs, containing an implementation of the lookup protocol; and the main source file, main.rs.
The main.rs file specifies the challenge:
pub fn main() {
welcome();
puzzle(PUZZLE_DESCRIPTION);
let p = 70937;
let irr = vec![3, 39025, 15086, 45405, 34941, 70897, 1]; // x^6 + 70897x^5 + 34941x^4 + 45405x^3 + 15086x^2 + 39025x + 3
let invalid_witness = FieldElement::new(vec![1<<15], p, irr.clone());
/* BEGIN HACK */
let witness = vec![];
let m = vec![FieldElement::new(vec![0], p, irr.clone()); 1<<6];
/* END HACK */
let mut table = vec![];
for i in 0..1 << 6 {
table.push(FieldElement::new(vec![i], p, irr.clone()));
}
let l = witness.len(); // witness length
let t = table.len(); // table size
assert!(witness.contains(&invalid_witness));
let protocol = Protocol::new(l, t, p, irr.clone());
let statement = Statement::new(&table);
let w = Witness::new(&witness);
let msg1 = protocol.prove_round1(&w, &m);
let r = protocol.verify_round1(&msg1);
let msg2 = protocol.prove_round2(&r, &msg1, &statement);
assert!(protocol.verify_round2(&msg1, &msg2, &statement));
}
The code after the HACK
section creates table
, a vector containing all values of the range . It then runs the lookup protocol, using table
as the statement and witness
, a vector of field elements, as the witness. We are allowed to change witness
. From the context of the challenge, the clear interpretation is that the entries of the witness
vector will be looked up in the table
vector. There is also a vector m
of field elements we are allowed to change and which has length . The simplest explanation is that m[i]
is intended as the number of times that we wish to prove that table[i]
occurs in witness
.
The Solution Without Looking at the Protocol
There are two asserts that we need to make pass: 1) witness
must contain invalid_witness
, and 2) the protocol must succeed. There is little flexibility with regards to the first condition, so at least one component of witness
will have to be invalid_witness = 2<<15
, which unfortunately does not occur in table
. So let us focus on the second condition.
The protocol is implemented in protocol.rs, but that file is 253 lines long, so we don’t have time to read it all if we want to solve the challenge quickly. We can try to guess the critical feature of the protocol that is needed to solve the challenge. For this, we can ask ourselves how the protocol could capture the fact that the order in which the components of witness
are given should not matter. If the protocol ultimately performs its checks as equality checks in a finite field, how could it compute an element of a finite field from many elements in a manner that does not depend on the order?
Two easy ways to do so spring to mind: addition and multiplication. Hypothetically, we can guess that one possibility is that the protocol performs the check or , where are the components of witness
and is some kind of map that is applied to the witnesses. Let us for the moment assume that this is indeed what the protocol does and think back to our problem.
We must make at least one of the equal to , which does not appear in table
. This likely implies that we will not be able to cancel with a term on the right-hand side of the checked equation. Hence we must cancel this term with other terms on the left-hand side. Absent knowing what is, one way of doing this is to repeat sufficiently many times; the characteristic of is , meaning , and the order of the multiplicative group of is , so . If the check were done multiplicatively, then this wouldn’t be useful, as would be too large. However, is small enough that a witness of length is doable. We can thus try to use for witness
exactly many repetitions of , which hopefully cancels out and gives no contribution to the left-hand side of the check, and set m
to all zero:
/* BEGIN HACK */
let witness = vec![invalid_witness.clone(); p as usize];
let m = vec![FieldElement::new(vec![0], p, irr.clone()); 1<<6];
/* END HACK */
This indeed solves the challenge!
The Solution With Looking at the Protocol
The above solution strategy involves some intuition, and in our case, we were already sufficiently familiar with LogUp to know that it indeed carries out a check of the discussed form. Using some more information from the protocol implementation, the most promising place to start might be the verification of the second round, as this is what is ultimately asserted to return true and likely carries out the important checks:
pub fn verify_round2(&self, msg1: &ProverMessage1, msg2: &ProverMessage2, statement: &Statement) -> bool {
// Recompute challenge from msg1
let r = self.hash_to_field(msg1);
// Check sum(h_i) = sum(g_i)
let sum_h = msg2.h.iter()
.fold(FieldElement::new(vec![0], self.field_char, self.irr.clone()),
|acc, x| acc + x.clone());
let sum_g = msg2.g.iter()
.fold(FieldElement::new(vec![0], self.field_char, self.irr.clone()),
|acc, x| acc + x.clone());
if sum_h != sum_g {
return false;
}
// Check h_i·(w_i + r) = 1 for all i
for i in 0..self.l {
let one = FieldElement::new(vec![1], self.field_char, self.irr.clone());
let w_plus_r = msg1.w[i].clone() + r.clone();
if (msg2.h[i].clone() * w_plus_r) != one {
return false;
}
}
// Check g_i·(t_i + r) = m_i for all i
for i in 0..self.t {
let t_plus_r = statement.t[i].clone() + r.clone();
if (msg2.g[i].clone() * t_plus_r) != msg1.m[i] {
return false;
}
}
true
}
From this, we can see that the proof of the implemented protocol appears to consist, in particular, of elements and . The first check is that . The second and third checks then appear to verify that each and is computed correctly. If we remember the following two lines from the main function,
let l = witness.len(); // witness length
let t = table.len(); // table size
then we can read off from the two loops in verify_round2
that there is one for each component of the witness and one for each component of the table.
Given these observations, we can already (i.e., without even needing to fully parse the loops, assuming the comments weren’t present) arrive at the guess that will be of the form so that the first check is of the form as guessed above, the second verifies that each summand on the left-hand side has been correctly computed from , and the third verifies correct computation of the terms of the right-hand side. From there, we can proceed as before.
Puzzle 3: Shadow
This puzzle was written in Noir. The readme suggests that there is a whitelist that consists of hashes of the form pk_pepper
, where pk
is a public key and pepper
an associated secret value. We have one legitimate public-key–pepper pair as well as one other public key, and our goal is to be able to pass the checks by the circuit by using this target public key.
Understanding the Task
The Noir source code is not long:
// THIS FILE CANNOT BE CHANGED
use noir_string_search::{StringBody, StringBody128, SubString, SubString32};
// alice_pk = [155, 143, 27, 66, 87, 125, 33, 110, 57, 153, 93, 228, 167, 76, 120, 220, 178, 200, 187, 35, 211, 175, 104, 63, 140, 208, 36, 184, 88, 1, 203, 62]
// alice_pepper = [213, 231, 76, 105, 105, 96, 199, 183, 106, 26, 29, 7, 28, 234, 145, 69, 48, 9, 254, 205, 79, 21, 90, 13, 39, 172, 114, 59, 131, 15, 78, 118]
fn main(identifier: BoundedVec<u8, 128>, pub_key: pub [u8; 32], whitelist: pub [[u8; 32]; 10]) {
// the identifier hashes to a digest that is in the public whitelist
let digest = std::hash::sha256_var(identifier.storage(), identifier.len() as u64);
let mut present = false;
for i in 0..whitelist.len() {
if whitelist[i] == digest {
present = true;
}
}
assert(present);
// the specified public key is in the identifier
let id_haystack: StringBody128 = StringBody::new(identifier.storage(), 128);
let pk_needle: SubString32 = SubString::new(pub_key, 32);
let (result, _): (bool, u32) = id_haystack.substring_match(pk_needle);
assert(result);
}
The comments reveal the mentioned valid public-key–pepper pair to us. The circuit gets passed a private witness identifier
, consisting of up to 128 u8
components as well as public inputs pub_key
and the whitelist
. This data is provided from the Prover.toml file, in which pub_key
and whitelist
are fixed, while we are allowed to change the private witness identifier
:
# BEGIN HACK
[identifier]
[identifier]
len = "128"
storage = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# END HACK
The Solution
Going through the circuit, it first computes the SHA-256 hash of the first identifier.len()
components of identifier.storage()
and then asserts that one of the whitelist entries is equal to the resulting hash. After that, it checks whether pub_key
is a substring of the first 128 identifier.storage()
.
The only entry of whitelist
for which we know a preimage is the concatenation of alice_pk
with alice_pepper
, separated by an underscore. Note that this preimage is of length 65. As long as SHA-256 is not sufficiently broken for us to find preimages or collisions, we must thus use identifier.len() = 65
and populate the first 65 components of identifier.storage()
with this known preimage. This will pass the first assert. For the second one, we can note that there are still 128 - 65 = 63
components of identifier.storage()
left in which to store the length-32 substring pub_key
, so this is plenty.
Using an interactive Python interpreter, we can very quickly compute a list that can be used for identifier.storage()
that solves the challenge:
>>> alice_pk = [155, 143, 27, 66, 87, 125, 33, 110, 57, 153, 93, 228, 167, 76, 120, 220, 178, 200, 187, 35, 211, 175, 104, 63, 140, 208, 36, 184, 88, 1, 203, 62]
>>> alice_pepper = [213, 231, 76, 105, 105, 96, 199, 183, 106, 26, 29, 7, 28, 234, 145, 69, 48, 9, 254, 205, 79, 21, 90, 13, 39, 172, 114, 59, 131, 15, 78, 118]
>>> pub_key = [157, 133, 167, 136, 43, 161, 75, 166, 33, 14, 35, 106, 238, 18, 60, 56, 93, 209, 205, 52, 247, 110, 174, 192, 20, 58, 70, 42, 215, 98, 195, 150]
>>> alice_pk + [ord('_')] + alice_pepper + pub_key + [0]*(128-32-1-32-32)
[155, 143, 27, 66, 87, 125, 33, 110, 57, 153, 93, 228, 167, 76, 120, 220, 178, 200, 187, 35, 211, 175, 104, 63, 140, 208, 36, 184, 88, 1, 203, 62, 95, 213, 231, 76, 105, 105, 96, 199, 183, 106, 26, 29, 7, 28, 234, 145, 69, 48, 9, 254, 205, 79, 21, 90, 13, 39, 172, 114, 59, 131, 15, 78, 118, 157, 133, 167, 136, 43, 161, 75, 166, 33, 14, 35, 106, 238, 18, 60, 56, 93, 209, 205, 52, 247, 110, 174, 192, 20, 58, 70, 42, 215, 98, 195, 150, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Conclusion
We would like to thank ZK Hack for organizing this competition and Bain Capital Crypto for creating these three interesting puzzles.
About Us
Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.
Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.
Contact us↗ for an audit that’s better than the rest. Real audits, not rubber stamps.