Repository navigation
fix(circuits): bound regex matches to the DKIM-signed lengths #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,6 +56,18 @@ fn main( | |
| remove_soft_line_breaks(body.storage(), decoded_body.storage()), | ||
| "Decoded body does not properly remove soft line breaks", | ||
| ); | ||
| // NOTE: the DKIM signature covers header[0..header.len()) and the body hash covers | ||
| // body[0..body.len()). Every regex below must read only those signed bytes (see | ||
| // `signed_decoded_len` for why storage past len() cannot be trusted). | ||
| assert( | ||
| x_handle_match_start + x_handle_match_length <= signed_decoded_len(body), | ||
| "Body regex match must lie within the signed body", | ||
| ); | ||
| assert( | ||
| sender_domain_match_start + sender_domain_match_length <= header.len(), | ||
| "Header regex match must lie within the signed header", | ||
| ); | ||
|
Comment on lines
+66
to
+69
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check whether capture IDs/starts and haystack bytes are constrained past match_length / after the accept state.
fd -t f 'sender_domain_regex.nr' test/fixtures --exec sh -c 'echo "== $1"; rg -n -C6 "fn check_transition_with_captures|reached_end_state|capture_group_1_id|fn capture_substring|select_subarray" "$1"' _ {}
# Check whether zkemail verify_dkim_signature / get_body_hash constrain header storage past len().
fd -t f -e nr . ~/nargo 2>/dev/null | rg -n 'dkim|body_hash' | headRepository: zkemail/ens-contracts Length of output: 9976 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '== helper definitions/usages =='
rg -n --glob '*.nr' 'fn (check_transition_with_captures|capture_substring|select_subarray|assert_zero_padded)|check_transition_with_captures\(|capture_substring\(|select_subarray\(|assert_zero_padded\(' .
printf '%s\n' '== relevant fixture call sites =='
for f in \
test/fixtures/handleCommand/circuit/src/main.nr \
test/fixtures/linkHandleCommand/discord/src/main.nr \
test/fixtures/linkHandleCommand/twitter/src/main.nr \
test/fixtures/redditHandleCommand/circuit/src/main.nr; do
echo "== $f =="
rg -n -C12 'assert_zero_padded|regex_match|header\.storage|verify_dkim|sender_domain' "$f"
done
printf '%s\n' '== changed-file status and revision availability =='
git diff --stat 7db51588ce55213a34f862de16ff628f9a790200 e0bf259247855d3f6a91b112af64386359ec9c64
git cat-file -e 7db51588ce55213a34f862de16ff628f9a790200^{commit}
git cat-file -e e0bf259247855d3f6a91b112af64386359ec9c64^{commit}Repository: zkemail/ens-contracts Length of output: 24577 🏁 Script executed: set -eu
printf '%s\n' '== helper definitions =='
rg -n --glob '*.nr' 'fn (check_transition_with_captures|capture_substring|select_subarray|assert_zero_padded)' .
printf '%s\n' '== helper source candidates =='
fd -t f -e nr . | rg 'zkregex|utils|capture|transition|subarray|padding'
printf '%s\n' '== header padding call sites =='
for f in test/fixtures/handleCommand/circuit/src/main.nr test/fixtures/linkHandleCommand/discord/src/main.nr test/fixtures/linkHandleCommand/twitter/src/main.nr test/fixtures/redditHandleCommand/circuit/src/main.nr; do
echo "== $f =="
rg -n -C8 'assert_zero_padded|regex_match|header' "$f" | head -120
doneRepository: zkemail/ens-contracts Length of output: 751 Reachability: External Zero-pad
Add Proposed fix assert_zero_padded(body);
assert_zero_padded(decoded_body);
+ assert_zero_padded(header);📍 Affects 4 files
🤖 Prompt for AI AgentsSource: Learnings |
||
|
|
||
| let pubkey_hash = pubkey.hash(); | ||
| let email_nullifier = poseidon::bn254::hash_1([poseidon_large(signature)]); | ||
| let x_handle_capture_1 = x_handle_regex::regex_match::<1920, 64>( | ||
|
|
@@ -83,3 +95,33 @@ fn main( | |
| x_handle_capture_1, sender_domain_capture_1, | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Number of leading bytes of the soft-line-break-decoded body that come from signed body bytes. | ||
| * | ||
| * REASON: BoundedVec storage past len() is a free prover input. sha256_var and | ||
| * partial_sha256_var_end only hash bytes [0, len()), zk-regex's select_subarray only bounds a | ||
| * match by the array capacity, and remove_soft_line_breaks decodes the whole storage, so bytes past | ||
| * body.len() also reach decoded_body. A regex match must therefore end before the decoded image of | ||
| * body.len(), otherwise the public outputs need not come from the signed email. | ||
| * | ||
| * NOTE: we bound matches instead of asserting the storage tail is zero, because legitimate input | ||
| * generators fill it differently: relayer-utils zero-pads, but zkemail.nr's JS generator leaves | ||
| * SHA-256 padding there and sets decoded_body.len() past the signed content. decoded_body.len() is | ||
| * therefore not trusted either. Each "=\r\n" that starts inside the signed body removes 3 bytes. A | ||
| * break that straddles body.len() is also counted, which only makes the bound stricter. | ||
| */ | ||
| fn signed_decoded_len<let N: u32>(body: BoundedVec<u8, N>) -> u32 { | ||
| let storage = body.storage(); | ||
| let len = body.len(); | ||
| let mut removed: u32 = 0; | ||
| for i in 0..N - 2 { | ||
| let is_break = (storage[i] == 0x3D) & (storage[i + 1] == 0x0D) & (storage[i + 2] == 0x0A); | ||
| removed += ((is_break & (i < len)) as u32) * 3; | ||
| } | ||
| if removed > len { | ||
| 0 | ||
| } else { | ||
| len - removed | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| #!/usr/bin/env python3 | ||
| """Regression test: regex matches must come from DKIM-signed bytes. | ||
|
|
||
| Runs `nargo execute` on a circuit with: | ||
| - the sample inputs, and the same email laid out the way other input generators | ||
| lay it out (SHA-256 padding left in the storage tail), which must succeed; | ||
| - inputs that copy a regex match into BoundedVec storage past `len()`, which | ||
| must fail. | ||
|
|
||
| NOTE: bytes of `header`, `body` and `decoded_body` at index >= len() are not | ||
| covered by the DKIM signature or the body hash, so a circuit that lets a regex | ||
| read them would output values that are not in the signed email. | ||
|
|
||
| Usage (needs nargo 1.0.0-beta.5 on PATH): | ||
| ./check_signed_bounds.py twitter | ||
| ./check_signed_bounds.py <circuit_dir> <prover_toml> <body_regex_name> | ||
| """ | ||
|
|
||
| import os | ||
| import re | ||
| import shutil | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
|
|
||
| HERE = os.path.dirname(os.path.abspath(__file__)) | ||
| PRESETS = { | ||
| "twitter": (os.path.join(HERE, "twitter"), "Prover.sample.toml", "x_handle"), | ||
| } | ||
|
|
||
|
|
||
| def section(text, name): | ||
| m = re.search(r"(\[%s\]\s*\n)(.*?)(?=\n\[|\Z)" % re.escape(name), text, re.S) | ||
| assert m, name | ||
| return m | ||
|
|
||
|
|
||
| def get_list(body, key): | ||
| raw = re.search(r"(?m)^%s\s*=\s*\[([^\]]*)\]" % key, body).group(1) | ||
| return [int(x.strip().strip('"'), 0) for x in raw.split(",") if x.strip()] | ||
|
|
||
|
|
||
| def get_int(text, key): | ||
| return int(re.search(r'(?m)^%s\s*=\s*"?(\d+)"?' % key, text).group(1)) | ||
|
|
||
|
|
||
| def set_list(body, key, values): | ||
| return re.sub( | ||
| r"(?m)^(%s\s*=\s*)\[[^\]]*\]" % key, | ||
| lambda m: m.group(1) + "[" + ", ".join('"%d"' % v for v in values) + "]", | ||
| body, | ||
| count=1, | ||
| ) | ||
|
|
||
|
|
||
| def set_int(text, key, value): | ||
| return re.sub(r'(?m)^(%s\s*=\s*)"?\d+"?' % key, lambda m: m.group(1) + '"%d"' % value, text, count=1) | ||
|
|
||
|
|
||
| def write_tail(text, name, payload, at=None): | ||
| """Write payload into `name`'s storage at `at` (default: len), leaving len unchanged.""" | ||
| m = section(text, name) | ||
| storage = get_list(m.group(2), "storage") | ||
| start = get_int(m.group(2), "len") if at is None else at | ||
| storage[start : start + len(payload)] = list(payload) | ||
| body = set_list(m.group(2), "storage", storage) | ||
| return text[: m.start(2)] + body + text[m.end(2) :], start | ||
|
|
||
|
|
||
| def body_tail_case(text, regex): | ||
| # Copy the signed body match (same NFA states) into the unsigned tail of both body arrays, | ||
| # then point the body regex at the copy in decoded_body. | ||
| m = section(text, "decoded_body") | ||
| storage = get_list(m.group(2), "storage") | ||
| start, length = get_int(text, regex + "_match_start"), get_int(text, regex + "_match_length") | ||
| payload = bytes(storage[start : start + length]) | ||
| text, _ = write_tail(text, "body", payload) | ||
| text, tail = write_tail(text, "decoded_body", payload) | ||
| return set_int(text, regex + "_match_start", tail) | ||
|
|
||
|
|
||
| def header_tail_case(text, _regex): | ||
| m = section(text, "header") | ||
| storage = get_list(m.group(2), "storage") | ||
| start, length = get_int(text, "sender_domain_match_start"), get_int(text, "sender_domain_match_length") | ||
| text, tail = write_tail(text, "header", bytes(storage[start : start + length])) | ||
| return set_int(text, "sender_domain_match_start", tail) | ||
|
|
||
|
|
||
| def sha_pad_tail(storage, length, total_len): | ||
| """SHA-256-pad storage[length:] in place, the way sha256Pad-based generators do.""" | ||
| storage[length] = 0x80 | ||
| end = ((length + 1 + 8 + 63) // 64) * 64 | ||
| if end <= len(storage): | ||
| storage[end - 8 : end] = list((total_len * 8).to_bytes(8, "big")) | ||
|
|
||
|
|
||
| def decode_soft_breaks(storage): | ||
| out, i = [], 0 | ||
| while i < len(storage): | ||
| if i + 2 < len(storage) and storage[i : i + 3] == [0x3D, 0x0D, 0x0A]: | ||
| i += 3 | ||
| else: | ||
| out.append(storage[i]) | ||
| i += 1 | ||
| return out + [0] * (len(storage) - len(out)), len(out) | ||
|
|
||
|
|
||
| def js_generator_layout(text, _regex): | ||
| # zkemail.nr's JS generator leaves SHA-256 padding past header.len() and body.len(), decodes | ||
| # soft line breaks over the whole storage, and sets decoded_body.len() to the decoded length | ||
| # of that whole storage. Valid email, so it must still prove. | ||
| for name in ("header", "body"): | ||
| m = section(text, name) | ||
| storage = get_list(m.group(2), "storage") | ||
| length = get_int(m.group(2), "len") | ||
| sha_pad_tail(storage, length, length) | ||
| text = text[: m.start(2)] + set_list(m.group(2), "storage", storage) + text[m.end(2) :] | ||
| if name == "body": | ||
| decoded, count = decode_soft_breaks(storage) | ||
| d = section(text, "decoded_body") | ||
| body = set_list(d.group(2), "storage", decoded) | ||
| body = re.sub(r'(?m)^(len\s*=\s*)"?\d+"?', lambda mm: mm.group(1) + '"%d"' % count, body) | ||
| text = text[: d.start(2)] + body + text[d.end(2) :] | ||
| return text | ||
|
|
||
|
|
||
| def run(circuit_dir, prover_text, label): | ||
| with tempfile.TemporaryDirectory() as tmp: | ||
| work = os.path.join(tmp, "circuit") | ||
| shutil.copytree(circuit_dir, work, ignore=shutil.ignore_patterns("target", "files")) | ||
| with open(os.path.join(work, "Prover.toml"), "w") as f: | ||
| f.write(prover_text) | ||
| res = subprocess.run(["nargo", "execute"], cwd=work, capture_output=True, text=True) | ||
| print("[%s] nargo execute -> exit %d" % (label, res.returncode)) | ||
| return res.returncode == 0, res.stdout + res.stderr | ||
|
|
||
|
|
||
| def main(): | ||
| if len(sys.argv) == 2: | ||
| circuit_dir, prover, regex = PRESETS[sys.argv[1]] | ||
| elif len(sys.argv) == 4: | ||
| circuit_dir, prover, regex = sys.argv[1], sys.argv[2], sys.argv[3] | ||
| else: | ||
| sys.exit(__doc__) | ||
| with open(os.path.join(circuit_dir, prover)) as f: | ||
| base = f.read() | ||
|
|
||
| for label, text in (("valid", base), ("valid-sha-padded-tail", js_generator_layout(base, regex))): | ||
| ok, out = run(circuit_dir, text, label) | ||
| if not ok: | ||
| sys.exit("%s inputs failed:\n%s" % (label, out[-3000:])) | ||
|
|
||
| failures = [] | ||
| for label, mutate, expected in ( | ||
| ("body-tail", body_tail_case, "Body regex match must lie within the signed body"), | ||
| ("header-tail", header_tail_case, "Header regex match must lie within the signed header"), | ||
| ): | ||
| ok, out = run(circuit_dir, mutate(base, regex), label) | ||
| if ok or expected not in out: | ||
| failures.append("%s: expected failure '%s'\n%s" % (label, expected, out[-2000:])) | ||
| if failures: | ||
| sys.exit("\n".join(failures)) | ||
| print("all signed-bounds checks passed") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: zkemail/ens-contracts
Length of output: 42183
🏁 Script executed:
Repository: zkemail/ens-contracts
Length of output: 25032
🏁 Script executed:
Repository: zkemail/ens-contracts
Length of output: 12245
🌐 Web query:
github.com/zkemail/zk-regex tag 2.2.0 noir capture_substring check_transition_with_captures💡 Result:
🏁 Script executed:
Repository: zkemail/ens-contracts
Length of output: 2511
🏁 Script executed:
Repository: zkemail/ens-contracts
Length of output: 18971
Authorization Bypass
Reachability: External
Exploitability: Moderate
CWE: CWE-345
Constrain capture metadata after the matched range. After
match_length - 1,reached_end_statedisables transition constraints, butcapture_substringstill processes the full window. A prover can therefore place a capture endpoint after the signed match and expose unsigned storage bytes.In each cited regex circuit, assert
capture_group_1_id[i] == 0for everyi >= match_length. Add acheck_signed_bounds.pymutation with a valid in-range match and a capture that extends past its end. Do not useassert_zero_padded(header)because the supported SHA-padded layout is not zero-filled.📍 Affects 4 files
test/fixtures/handleCommand/circuit/src/main.nr#L63-L63(this comment)test/fixtures/linkHandleCommand/discord/src/main.nr#L66-L66test/fixtures/linkHandleCommand/twitter/src/main.nr#L66-L66test/fixtures/redditHandleCommand/circuit/src/main.nr#L66-L66View in Security blast radius