← yescrow security review

Published copy of reports/AI pentest, fifth run (2026-11-06).md from yescrow's private repository, unedited apart from local file paths. File and test references point into that repository; the contracts' source is verified on Arbiscan (see the contracts). Download as Markdown.

AI penetration test, fifth run (2026-11-06)

A short targeted step 4 of docs/ai-pentest.md on commit 1c4a7d0, as the launch gate asks for a change to a deploy script (docs/mainnet.md §1, gate 1): that commit pins mainnet's own attestor key, 0x2164…82b8, in contracts/script/Networks.sol, after the testnet's attestor key leaked on 2026-11-03 (docs/secrets.md §7). The contracts in contracts/src/ are unchanged since the fourth run's commit 1e08fee. One fresh attack agent on area F (deployment), aimed at everything in the deploy path since 1e08fee: v0.34's deploy-script changes, which no run had attacked, and the new pin. Every earlier proof ran as a regression test (the full suite, 829 tests, passes on 1c4a7d0), and a fresh verifier checked each finding. Proofs are under contracts/test/pentest/fifth/ and scripts/pentest/fifth/; the verifier's test is contracts/test/pentest/verify/V5_F-2_AttestorPinChecks.t.sol.

Part 1. Summary

Result

No Critical, High or Medium finding, and no finding needs a contract change. What deploys is what was audited: the creation-code pins are this build and the deploy and gate scripts embed exactly those bytes; the via-IR/legacy split fails closed; the FOUNDRY_*/DAPP_* refusal reads the only .env forge loads; on Arbitrum One today the deployer is unused, the promised escrow addresses are empty, the judge Safe is as documented, and the new attestor pin is a fresh address, neither the leaked testnet key nor Reclaim's. The ops tool's key-to-address code (which printed the pin) matches cast on 49 keys.

One Low and one Info finding survived verification, both in the procedure around the contracts:

All findings

"Attacker" is the severity the attack agent claimed; "Verifier" is the fresh agent's verdict; "Final" is the verifier's severity. The rule (the user's): a finding whose proof runs and shows the claimed impact can't be dropped by argument alone.

ID Finding Attacker Verifier Final Where the fix goes
R5-F-1 The launch scripts put the deployer, promo and (rehearsal) relayer keys on cast command lines every local account can read Medium Confirmed, lowered: the promo and relayer keys already reach teo's code through the services; the deployer key leaks in every rehearsal, so the launch-day race doesn't matter Low Scripts: the pinned addresses instead of keys on Arbitrum One, a throwaway key for the rehearsal's router, a forge-side key check; the server: /proc with hidepid=invisible
R5-F-2 No step before the deployment proves the pinned attestor address is the key the mainnet attestor signs with Low Confirmed, lowered: the vault ties the address to the key at secret gen; only a hand edit breaks it, and the old key stays in retired/; "no money enters" corrected (same-registrar deals) Info DeployAll: an unconditional key-to-pin check on mainnet; yescrow-ctl: secret set can't change a key without its address; the runbook: a live two-attestor proof between §4 and §5

Six ideas stayed unconfirmed (U-1 to U-6 in the area report): a lying RPC provider, the judge Safe's fallback handler not compared with Safe's canonical list, a tampered compiler, an attestor started without its key, RPC URLs with API keys on command lines, and a failed cast nonce after a failed send.

The R5-F-1 verifier also asked whether the attestor container, which has no user: line in docker-compose.yml, runs as uid 1000 (then teo could read its key from /proc/<pid>/environ). Checked on the server on 2026-11-06: no container process runs as uid 1000; the prover and service run as 10001, the attestor as root inside its container, whose environment teo can't read. The R5-F-2 verifier noted that the first vault backup predated the attestor key: the user made a second encrypted backup after secret gen the same day.

Limits


Part 2. Area report, with each verification

Area F: deployment

AI penetration test, fifth run, Area F: deployment (commit 1c4a7d0)

Targeted at what changed in the deploy path since the last attacked commit 1e08fee (v0.33): v0.34's deploy-script changes (never attacked by a run) and 1c4a7d0's YESCROW_ATTESTOR pin. Attack model and rules: docs/ai-pentest.md. Work was done on an export of 1c4a7d0 only; nothing under contracts/src/, contracts/script/, scripts/*.sh, ops/ or docs/ was changed; no .env file, key, vault, sudo, yescrow-ctl, Docker or running stack was touched; nothing was sent to a live network (reads from a fork of Arbitrum One at the latest block, a local forge script without RPC, cast on throwaway keys from /dev/urandom). Date 2026-11-06, forge 1.8.5, Arbitrum One block 26,355,312. Attack agent: Claude Fable 5.1, maximum reasoning effort.

New files: contracts/test/pentest/fifth/F5_ArbitrumOneToday.t.sol (5 tests, pass: the new pins against the chain today, DeployAll's pre-broadcast check, the gate's expectations); contracts/test/pentest/fifth/F5_AttestorPinUnlinked.t.sol (3 tests, pass: R5-F-2); scripts/pentest/fifth/F5_private_keys_on_command_lines.sh (passes: R5-F-1).

cd contracts && FOUNDRY_DYNAMIC_TEST_LINKING=false systemd-run --user --scope -p MemoryMax=3300M -p MemorySwapMax=3G --quiet -- \
  ~/.foundry/bin/forge test --match-path 'test/pentest/fifth/F5_*.t.sol' -vv      # 8 passed
scripts/pentest/fifth/F5_private_keys_on_command_lines.sh                           # PASS

Headline. The v0.34 pins and refusals hold: this checkout builds exactly the creation code Networks.sol pins, the deploy and gate scripts embed those very bytes (what the tests run is what deploys), the via-IR copy of DeployRouter.s.sol that a test build leaves behind is neither linked nor accepted, the FOUNDRY_*/DAPP_* refusal looks at the only .env forge reads, and on Arbitrum One today the deployer is untouched, the promised addresses are empty, the judge Safe is what the docs describe, and the new attestor pin is a fresh address that is neither the leaked testnet key nor Reclaim's. Two gaps remain, both in the procedure around the contracts rather than in what they deploy: the launch scripts put mainnet private keys on command lines every local account can read (R5-F-1, Medium), and nothing before the one-shot deployment proves the pinned attestor address belongs to the key the mainnet attestor runs with (R5-F-2, Low).

Findings

R5-F-1. The launch scripts put the mainnet deployer, promo and (in the rehearsal) relayer keys on cast command lines, readable by every local account

R5-F-2. No step before the one-shot deployment proves that the pinned attestor 0x2164…82b8 is a key yescrow's attestor signs with

Unconfirmed

Checked and found sound

Verifications for area F

Each by a fresh agent (Claude Opus 5.5, maximum reasoning effort) that tried to refute the finding and ran its proof again, under the user's rule.

R5-F-1: keys on cast command lines — Verdict: CONFIRMED, lowered to Low

Severity: Low. The leak is real and the proof shows it. But the impact that carries the Medium (yescrow's fees lost to a leaked promo key) is not something this finding newly hands the attacker: code running as teo already reaches the promo and relayer keys through the gap docs/secrets.md accepts ("code the agent writes runs with keys"): the mainnet images are built from teo's working tree (ops/yescrow-ctl, compose_cmd/project_dir/cmd_up; docker-compose.yml build:), and the service container gets every mainnet secret. The fee at stake is also small: vouchers work only on open deals before promoEnds (YescrowEscrow.sol:755–756), and NAMEBROS already waives each seller's first fee for 90 of those 92 days. The key this finding newly exposes is the deployer, the only one kept out of the services after the keystore import; its worst case is the promised addresses used or occupied and the launch held up for a new deployer, new pins and an audit round, with no party's money and no fee lost. Runs 4 and 5 graded that impact Low (R4-F-1). The race is not a reason to lower it further: cast lives 13–20 ms here, the poller caught it at the first try, and the rehearsal hands over the deployer key before launch day.

Proof result. bash scripts/pentest/fifth/F5_private_keys_on_command_lines.sh (output masked for any 64-hex string): exit 0, PASS: R5-F-1 reproduced with a throwaway key. /proc mounted rw,nosuid,nodev,noexec,relatime (no hidepid); /proc/1/cmdline -r--r--r-- root, read by uid 1000; one capture of the whole throwaway key from cast's argv; --interactive fails without a tty. Extra: cast 1.8.5 also refuses the key from stdin and from PRIVATE_KEY/ETH_PRIVATE_KEY; cast wallet address --private-key lived 13–20 ms over 8 runs. The capture was of a teo process; reading another account's argv follows from the pid-1 read plus no hidepid (cmdline has no ptrace check).

Corrections to the finding: - The deployer key leaks in every rehearsal (rehearse-mainnet.sh:25) and after any failed forge run (deploy-all.sh:118), not only on launch day. Gate 5 comes before funding (§2), so with a captured key the attacker can fund the deployer and spend nonce 0 at leisure; "it holds 0.01 ETH then" and the race from :75 to :115 don't matter. DeployAll then refuses the launch (DeployAll.s.sol:165). - The promised addresses can be occupied, not just burned: creations at nonces 3 and 4 put the attacker's code at Networks.sol:38–39. 0xe13E…aBa is already public (the first run's report). Nothing trusts these addresses before the deployment record exists, so this is at most a phishing aid. - The promo and relayer consequences are not new to this attacker (above); the Medium rests on them without saying so. - "Lost on every deal it is used on" overstates: given the promo window and NAMEBROS, the extra loss is repeat sellers' deals, deals past 1,000 uses, and days 90–92. - The redaction point is right, and the nightly scan is blind too: after secret rm, neither the guard's hashes nor the scan (both built from the vault files only; removed values go to retired/) know the deployer key. The promo and relayer keys stay in the vault, so the guard would redact them in a ps line; the transcript risk is the deployer key only. - New lines: deploy-all.sh:75 and :118 are v0.34's; :88, :93 and rehearse-mainnet.sh:25–26 are older, so run 4 missed them. :93 also runs on every RESUME=1 run (it sits outside the RESUME branch). - cast also takes the key as a positional argument (argv too). In the suggested fix, ETH_PASSWORD is the variable for --password-file (a path), and --password would put the keystore password on argv. - :88 at launch: whether mainnet.env sets YESCROW_ATTESTOR can't be checked from the copy; secret gen/rotate update that line only when it exists. It is consistent with docs/keys.md and .env.mainnet.example; if it were missing, :88 would put the attestor key on argv. - Not in the finding, not verified: the attestor service has no user: (unlike the prover and service). If its image ran as uid 1000, teo could read its key from /proc/<pid>/environ. (Checked afterwards on the server: it runs as root in its container; see Part 1.)

Fix assessment: - The pins work on arbitrum-one: :75 and :118 can use MAINNET_DEPLOYER, :93 MAINNET_PROMO_SIGNER; DeployAll already refuses any other deployer or promo signer (DeployAll.s.sol:148, :169). A forge-side vm.addr(vm.envUint(…)) check ties the vault's promo key to its pin. Keep it per network (on Arbitrum Sepolia the deployer isn't the pin) and rework :92–102, where the key's presence decides whether the escrows get a promo signer at all. - The rehearsal: :25 can use the pin; :26 has none. Simpler than a forge one-liner: drop the relayer key from the rehearsal. DeployRouter takes any key but the mainnet deployer and the router's address doesn't depend on it, so a throwaway key funded with anvil_setBalance does the same. - Keeping each key's address in the vault needs more than the finding says (secret gen/rotate only, and only when the line exists; the promo and deployer keys predate the vault; a DEPLOYER line would outlive its key after the keystore import). The pins are simpler. - The keystore route works but is redundant: §3 step 0 decrypts the key into .env.mainnet anyway. - Complete for argv: no other place in these scripts or the documented commands (echo is a builtin, awk sees only its program, the rest are environment assignments or interactive; yescrow-ctl passes secrets by env file, in-process HTTP or /dev/fd; no secret-bearing command: in docker-compose.yml). - RPC URLs: ETH_RPC_URL covers cast and forge, but anvil 1.8.5's --fork-url has no environment variable and lives for the whole rehearsal; today's URLs are public. - Mounting /proc with hidepid=invisible is the only change that closes this for every other account, including future slips; make it part of the fix. Root is unaffected; give admin a gid= group only if it needs to see other users' processes, and check polkit after remounting. - The claude-guard additions only stop accidental ps output in sessions; a script bypasses them.

R5-F-2: the attestor pin and the key — Verdict: CONFIRMED, lowered to Info

Severity: Info. The proof shows what it says: the deploy script and the launch gate take the attestor only as an address and never derive it from a key. But the vault already ties the address to the key: secret gen and secret rotate write the key's address into YESCROW_ATTESTOR (ops/yescrow-ctl:369, :378–381), DeployAll compares that with the pin before anything is sent (DeployAll.s.sol:167), and deploy-all.sh:88 derives it from the key when it is unset. None of the named preconditions gets past that: a vault restored from before 2026-11-06 is refused before any send (verifier's test b), a typo in the pin doesn't compile, and an attestor started without its key is fixed by a restart. A real mismatch needs a hand change that skips the address (secret set, or sudoedit of mainnet.env), and even then secret set keeps the pinned key in retired/, so the contracts are lost only if that key is gone too. In the documented order no third party's money is at risk: §5's smoke test runs with the user's own wallets before opening. The closest precedent, R4-F-I5 (the judge pin not tied to anything yescrow holds, fails closed on typos), was Info. Fix (1) is still worth adding as cheap hardening.

Proof result. F5_AttestorPinUnlinked.t.sol: 3 passed (cached build, fork of Arbitrum One). The fixture's yescrow half is signed by 0xaac9…8975 while the mainnet verifier needs 0x2164…82b8; the 1,000 USDC deal isn't accepted. Tests 1 and 2 show what is claimed (a grep confirms the pin appears only in Networks.sol and docs/keys.md; no attestation by the pinned key exists in the repository); test 3 covers only a different-registrar deal and doesn't support the general "no money enters" (correction below). The verifier's own test, contracts/test/pentest/verify/V5_F-2_AttestorPinChecks.t.sol, 2 passed: (a) a same-registrar 1,000 USDC deal over the pinned verifier is accepted and funded with no proof; its proof then reverts WrongAttestor, and the refund after 10 days returns the 1,000 USDC to the buyer; (b) an environment shaped like the vault before 2026-11-06 (YESCROW_ATTESTOR=0xaac9…) is refused by DeployAll.check; (c) fix (1)'s check on throwaway keys passes for the matching key and refuses another; an empty value and an unprefixed key make vm.envUint revert. Not re-run: the attack agent's comparison of key_info with cast.

Corrections to the finding: - "Every deal needs a passing before proof (YescrowEscrow.sol:442), so no money ever enters" is wrong: the before proof is checked only for different-registrar deals (YescrowEscrow.sol:441; SPEC: "This mode needs no 'before' proof"). A same-registrar deal takes the buyer's money at accept; its proof then fails (:471), and it ends only by confirm or cancel (:500–511), a seller's dispute (:518–523), or a refund after the transfer window (:493–497); docs/keys.md already says so for a lost attestor key. The same premise is in the proof's comments (F5_AttestorPinUnlinked.t.sol:44–45, :125–126). The §5 smoke test is itself a same-registrar push, so it shows the problem only after the user's own ~5 USDC is in; their own wallets, so cancel or confirm recovers it. - "Nothing ties the pin to a key" overstates: the vault's address update plus DeployAll.s.sol:167 tie the pin to the key's address as it was at gen or rotate time; :88 derives it when unset; deploy-all.sh:39 exports the whole env file, so the key is already in forge's environment. The real gap is narrower: secret set and sudoedit can change the key without the address, and nothing re-derives it at deploy time. - The named preconditions don't lead to a passing deployment: a vault restored from before secret gen holds YESCROW_ATTESTOR=0xaac9… and no key (refused at :167, or :88 stops for lack of a key); a typo in the pin fails solc's EIP-55 checksum, and a wrong valid address disagrees with the vault and is refused at :167; a container started without the key loses nothing deployed. - "The five contracts are wasted" holds only if the pinned key is also unrecoverable: secret set/rotate move the old value to retired/, and the vault backup includes that folder. Otherwise the remedy is to put the key back and restart the attestor. - Two cited lines are off: the fork test's testnet attestor constant is at ArbitrumOneMainnetFork.t.sol:52, and key_info at ops/yescrow-ctl:354–360. Every other cited line checks out. - Missed: docs/secrets.md listed the first vault backup before the attestor key was made; without a newer backup, losing the vault would lose the pinned key (DeployAll would refuse safely, at the cost of a new pin and an audit round). (The user made a newer backup the same day; see Part 1.)

Fix assessment: - Fix (1) works and fits how the deploy gets its environment: deploy-all.sh:39 exports .env.mainnet, a whole copy of the vault with the attestor key (§3 step 0), and the rehearsal uses the same file; the vault stores keys as 0x plus 64 hex digits, which vm.envUint parses; the key is already in forge's owner-only environment, so this doesn't reopen R5-F-1; it checks the exact value the attestor container gets, independently of the ops tool's own code. - Make it unconditional on mainnet, with a clear message: with vm.envExists, a file without the key line skips the check, exactly the restored-vault case if an admin hand-sets YESCROW_ATTESTOR. An empty value (the example ships ATTESTOR_PRIVATE_KEY=) makes vm.envUint revert: safe, but the message should name the problem. - Also fix the root cause in the ops tool: have secret set update the companion address, or refuse set for ATTESTOR_PRIVATE_KEY and require rotate; have scan and up mainnet compare the key's address with YESCROW_ATTESTOR. That also covers changes after the deployment, which (1) can't. - Fix (2), a live proof before §3, isn't feasible in the documented order: before §4 the testnet attestor holds 127.0.0.1:8001 (the mainnet attestor's default port), the mainnet prover's attestor URL defaults to wss://yescrow.xyz/attestor/ws, which Caddy routes to the testnet attestor (a false alarm), the prover publishes no port, and the mainnet service can't start without the deployment record. It would add little beyond what (1) proves offline. - The better place for a live check is between §4 and §5: POST /v1/proofs on the mainnet API with both attestors, then an eth_call of verify on the deployed DualRdapVerifier, as done on testnet. It costs nothing, covers an attestor started without its key (U-4), and runs before the smoke test puts USDC into a deal that needs no proof to start. That proof can become the mainnet fixture, with ArbitrumOneMainnetFork.t.sol:52 then pointing at Networks.YESCROW_ATTESTOR.