Skip to main content
Table of contents
Malte Leip

Breaking Down the Puzzles in ZK Hack V

A look into the three puzzles solved by the Zellic cryptography team for ZK Hack V
Article heading

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.

ZK Hack Puzzle V-2 Results
ZK Hack Puzzle V-3 Results

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, &params, &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 2k2^k, and exended_omega an element of degree 2kj2^k \cdot j. 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 2k2^k (the presumed multiplicative order of omega, so that omega2k1=omega1\texttt{omega}^{2^k - 1} = \texttt{omega}^{-1}).

Finally, recall that the secret was contained in the column at indexes 2, 3, and 4. Checking evaluation at omega2\texttt{omega}^2, 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 omega2\texttt{omega}^2. 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 omega2\texttt{omega}^2 to obtain the secret. For this to work, there are three things we need to check:

  1. The assumption that values added to the transcript with transcript.write_scalar really are extractable from the proof files we were given
  2. At what points the three evalutations are done — we also must be able to calculate those from the proofs
  3. 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 xomegaix \cdot \texttt{omega}^i. 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: xx, xomegax \cdot \texttt{omega} and xomega1x \cdot \texttt{omega}^{-1}. 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<_>>(&params);

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<_>>(&params);

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 Fp6\mathbb{F}_{p^6}, with pp a 16-bit prime. Our task is to make the protocol accept a “proof” that 2152^{15} lies in the range [0,261][0, 2^6 - 1].

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 [0,261][0, 2^6 - 1]. 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 262^6. 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 f(wi)=?\sum f(w_i) = ? or f(wi)=?\prod f(w_i) = ?, where w1,,wlw_1, \dots, w_l are the components of witness and ff 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 wiw_i equal to 2152^{15}, which does not appear in table. This likely implies that we will not be able to cancel f(215)f(2^{15}) 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 ff is, one way of doing this is to repeat f(215)f(2^{15}) sufficiently many times; the characteristic of Fp6\mathbb{F}_{p^6} is pp, meaning pf(215)=0p \cdot f(2^{15}) = 0, and the order of the multiplicative group of Fp6\mathbb{F}_{p^6} is p61p^6 - 1, so f(215)p61=1f(2^{15})^{p^6 - 1} = 1. If the check were done multiplicatively, then this wouldn’t be useful, as p61p^6 - 1 would be too large. However, pp is small enough that a witness of length pp is doable. We can thus try to use for witness exactly pp many repetitions of 2152^{15}, 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 hih_i and gig_i. The first check is that ihi=igi\sum_i h_i = \sum_i g_i. The second and third checks then appear to verify that each hih_i and gig_i 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 hih_i for each component of the witness and one gig_i 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 hih_i will be of the form hi=f(wi)h_i = f(w_i) so that the first check is of the form f(wi)=?\sum f(w_i) = ? as guessed above, the second verifies that each summand on the left-hand side has been correctly computed from wiw_i, 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.