Published copy of reports/AI pentest, sixth 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, sixth run (2026-11-06)
A short targeted step 4 of docs/ai-pentest.md on commit d451174, the fixes for the fifth run
(reports/AI pentest, fifth run (2026-11-06).md), as the launch gate asks for a change to a deploy script
(docs/mainnet.md §1, gate 1). d451174 changed scripts/deploy-all.sh and scripts/rehearse-mainnet.sh (the
pinned addresses instead of keys on command lines, a throwaway router key in the rehearsal),
contracts/script/DeployAll.s.sol (a check of the attestor and promo keys before anything is sent), ops/yescrow-ctl
(secret set keeps the attestor's address with its key; lockdown hides each account's processes in /proc) and the
launch procedure. The contracts in contracts/src/ are unchanged since 1e08fee. One fresh attack agent on area F,
aimed at those changes. On d451174 the attack agent re-ran the fifth run's proofs, its verifier's test and the
build pins (12 of 12 pass), the R5-F-1 regression (no key on any command line) and the ops tool's tests (no failure);
the whole suite, which no longer fits this server's memory in one run, runs in parts with this run's fixes
(docs/audit.md §6e). A fresh verifier checked each finding. Proofs are under contracts/test/pentest/sixth/ and scripts/pentest/sixth/; the verifiers' are
contracts/test/pentest/verify/V6_F-1_TryCatch.s.sol and scripts/pentest/sixth/verify/.
Part 1. Summary
Result
No Critical, High or Medium finding, and no finding needs a contract change. Both fifth-run fixes hold and are
complete for what they claim: no mainnet key reaches a command line in the launch scripts or the documented commands
(the regression logs every command the scripts run: none holds any of the five keys), DeployAll's key check runs
before the first send in the deployment and in the rehearsal, secret set keeps the attestor's address with its key,
and /proc on the server hides other accounts' processes since the admin ran lockdown during this round.
Two Low findings and one Info survived verification, all in the procedure around the contracts:
- Low: R6-F-1. A key line edited by hand with a stray character (a CR from a Windows paste, a non-ASCII space, a
tab inside quotes) makes forge print the whole key in its error and its trace: the deployer key
(
DeployAll.s.sol:74), the relayer key (DeployRouter.s.sol:43,docs/mainnet.md§3 step 1), and, as the verifier showed, the attestor and promo keys insideDeployAll's new key check, whose try/catch hides the error but not the trace of a failing run. Only on the admin's own screen, and none of the documented ways of writing a key line produces such a value. The verifier also showed that the suggested try/catch fix doesn't work; a check of each key's format in the shell, before forge runs, does. - Low: R6-F-2.
lockdown's/procstep appends its line to/etc/fstabwithout checking that the file ends with a newline; if it didn't, the last entry would be dropped silently at the next boot (on this server a swap file; elsewhere possibly the root file system). Not the case here: checked after the admin ran it, the file ended with a newline and every entry still generates its unit. The verifier added that the step takes a commented-out copy of its own line for done (U-3) and would then report the setting kept when it isn't. - Info: R6-F-3. The testnet path of
deploy-all.shaccepts the mainnet env file: with a Sepolia RPC in the admin's shell, it derives the mainnet deployer and promo addresses withcast(keys on command lines, now hidden from other accounts) and asks forge to deploy on Arbitrum Sepolia with the mainnet deployer. Nothing on Arbitrum One is affected. The verifier found the same in other testnet scripts (deploy-escrow.sh,fund-testnet-wallets.sh), that §3 step 1 leavesENV_FILE=.env.mainnetexported in the admin's shell, and thatDeployAll, unlikeDeployRouter, doesn't refuse the mainnet deployer on other chains.
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 |
|---|---|---|---|---|---|
| R6-F-1 | forge prints a whole private key in its error and trace when a key value holds a character it escapes | Low | Confirmed, wider: the attestor, promo and relayer keys too, through the trace of a failing run; the suggested try/catch doesn't stop it; only the admin's own screen | Low | The shell: check that each set *_PRIVATE_KEY is 0x and 64 hex digits before forge runs, without printing it (deploy-all.sh, and the router command of §3 step 1) |
| R6-F-2 | lockdown appends to /etc/fstab without a newline guard: the last entry is lost at the next boot |
Low | Confirmed: the boot drops the line without a message; a root entry last would leave root read-only; not the case on this server; U-3 is broader | Low | hide_processes: newline guard, re-read before appending, a line-aware check of live /proc entries, the generated units compared before and after |
| R6-F-3 | The testnet path of deploy-all.sh accepts the mainnet env file |
Info | Confirmed, wider: other testnet scripts, ENV_FILE exported by §3 step 1, DeployAll doesn't refuse the mainnet deployer off mainnet |
Info | DeployAll: refuse MAINNET_DEPLOYER on other chains; the testnet scripts refuse an env file made for Arbitrum One; §3 step 1 in a subshell |
Six ideas stayed unconfirmed (U-1 to U-6 in the area report): the boot-time half of the /proc step, a warning before
secret rotate replaces a pinned key, a commented-out /proc line taken for done (which the R6-F-2 verifier
reproduced in a likelier form), what checkKeys can't see (the running containers' keys; the live proof of §4 covers
the attestor's), RPC URLs with API keys on command lines, and the window before chmod 600 in §3 step 0.
Limits
- Targeted and short. One attack agent, on area F only; the rest was covered by the regression run of every earlier proof. The service changes since v0.33 were not attacked in this run either.
- Not independent of the author. Launched from the session that wrote the fixes, at the user's request; each agent had a fresh context and the procedure's prompt word for word; the attack agent used Fable 5.1, the verifiers Opus 5.5, all at maximum reasoning effort. No model from another vendor was run.
- Some points are read or reasoned, not run: the vault's contents (no agent can read them), the launch commands
as the admin runs them, the server's next boot with the new
/procline (the boot-time unit generator, run on the real/etc/fstab, produces every unit). - No findings is not proof of no bugs (the area report's "Checked and found sound" lists what was tried).
- Not fixed yet. The fixes follow the user's decisions; fixes to
deploy-all.shandDeployAll.s.solare themselves changes the gate asks a targeted round for.
Part 2. Area report, with each verification
Area F: deployment
AI penetration test, sixth run, Area F: deployment (commit d451174, the fifth run's fixes)
A short targeted round on what CHANGES-since-ab7980d.diff changed: the launch scripts' use of the pins, DeployAll.checkKeys, yescrow-ctl secret set and the new lockdown step hide_processes(), the rehearsal's throwaway key, and the live-proof step of docs/mainnet.md §4. Everything ran on a local copy, a local anvil or a fork of Arbitrum One (reads only); every key in the proofs is a throwaway one. The contracts in contracts/src/ are unchanged since 1e08fee.
Proofs: contracts/test/pentest/sixth/F6_KeyChecks.t.sol (4 tests, one on a fork), contracts/test/pentest/sixth/F6_KeyEcho.s.sol (a stand-in script the shell proof runs), scripts/pentest/sixth/F6_forge_output_no_keys.sh, scripts/pentest/sixth/F6_hide_processes.py. The fifth run's proofs and verifier test were re-run as regressions (12 of 12 pass; F5_fixed_no_keys_on_command_lines.sh passes: 0 keys in 14 logged commands and 69 watched command lines).
Result. The two fifth-run fixes hold and are complete for what they claim: no mainnet key reaches a command line in the launch scripts or the documented commands, checkKeys sits after check() and before the first send and is reached by the rehearsal, forge's traces redact what it reads, secret set keeps the attestor's address with its key, and /proc on this server is now mounted hidepid=invisible,gid=982 (the lockdown ran during this round: from the agent account only its own 17 processes are visible and /proc/1/cmdline does not exist). Three findings, none above Low, all in the procedure around the contracts; none needs a contract change.
Findings
R6-F-1. forge prints a whole private key in its error when an unparsable key value carries an escaped character
- Severity: Low. A key on the admin's screen and in whatever output the admin shares (a pasted failing run is the natural thing to show Claude, since "Claude prepares the commit and checks the results"). The deployer key is the one read this way; before launch its leak has R5-F-1's impact (someone can use the deployer's nonces and occupy the promised addresses, holding the launch for a new deployer, new pins and an audit round). No party's money. Narrow precondition (below).
- Attacker and preconditions: anyone who reads the admin's terminal output or a transcript it is pasted into. The value of
DEPLOYER_PRIVATE_KEY(orROUTER_DEPLOYER_PRIVATE_KEY) in the deploy clone's.env.mainnetmust fail to parse and contain a character Rust'sDebugescapes: a CR (a CRLF line ending from an editor or a Windows paste), a tab, a non-ASCII byte.yescrow-ctl secret setstrips CR and tab (checked),secret genwrites clean hex, and §3 step 0'secho "DEPLOYER_PRIVATE_KEY=$(cast wallet decrypt-keystore …)"is clean, so it takes a hand edit of.env.mainnetor of the vault file (sudoedit), or a paste of the decrypted key by hand. - Steps (
F6_forge_output_no_keys.sh, part 1, throwaway key):forge scriptof a stand-in with the shape ofDeployAll.s.sol:74(vm.envUint("DEPLOYER_PRIVATE_KEY"), no try/catch) and ofDeployRouter.s.sol:212(vm.envOr("ROUTER_DEPLOYER_PRIVATE_KEY", 0);envOrfalls back only when the variable is missing, not when it doesn't parse). With0x<key>Z, a quoted key or two keys, forge's message readsfailed parsing $F6_BAD as type uint256(the value replaced by its name). With0x<key>\r,0x<key>\t…or0x<key>+ U+00A0, three lines of the message carry the full key:failed parsing "0x<64 hex>\r" as type uint256: parser error: …. Reason: foundry redacts by replacing the value's plain spelling in the message with$NAME; the message holds theDebugform (\r,\t,\u{a0}escaped), so the replacement finds nothing. The same read insidecheckKeys's try/catch (the attestor and promo keys) prints nothing: the catch swallows the message and the script reports "missing or not the key of …". - Impact: the mainnet deployer key (launch and every rehearsal) on the admin's screen and in any shared output, for one malformed line in the env file. The attestor and promo keys are not affected. The traces at default verbosity and
-vvvvare clean (<env var value>,<pk>). - Command:
bash scripts/pentest/sixth/F6_forge_output_no_keys.shfrom the repository root (on the VPS inside the memory scope:FOUNDRY_DYNAMIC_TEST_LINKING=false systemd-run --user --scope -p MemoryMax=3300M -p MemorySwapMax=3G --quiet -- bash scripts/pentest/sixth/F6_forge_output_no_keys.sh). Passed:PASS: R6-F-1 reproduced …. - Root cause:
contracts/script/DeployAll.s.sol:74(vm.envUintoutside the try/catch thatkeyAddresshas);contracts/script/DeployRouter.s.sol:212–213; foundry'smap_env_err(forge 1.8.5) redacting the plain value only. - Suggested fix: read every key through a
keyAddress-style try/catch: inDeployAll.run,address deployer = keyAddress("DEPLOYER_PRIVATE_KEY"); require(deployer != address(0), "DEPLOYER_PRIVATE_KEY is missing or malformed"), withvm.envUintonly inside the try (the value never reaches a message); the same inDeployRouter. Indeploy-all.shandrehearse-mainnet.sh, refuse an env file whose*_PRIVATE_KEY=lines hold a CR, a tab or a non-ASCII byte (grep -nP '^[A-Z_]*_PRIVATE_KEY=.*[^\x20-\x7e]') before sourcing it. Optionally report upstream thatmap_env_errshould compare against theDebugform.
R6-F-2. hide_processes() appends its /proc line to /etc/fstab without a newline guard: an fstab whose last line has none loses that entry at the next boot
- Severity: Low. Not a lockout: the root entry is read,
/procis read; the entry the comment glues onto is dropped silently at the next boot, which is the rebootlockdownasks for right after. If that entry is the swap file (the usual last line; this VPS added an 8 GB swap file by hand on 2026-11-03 against its OOMs), the server comes back without it; if it is/boot/efi, without that mount. Precondition unverifiable from the copy: whether the server's/etc/fstabends with a newline (a handecho … >> /etc/fstabadds one; some editors and pastes don't). - Attacker and preconditions: none; root's own code.
/etc/fstabwithout a trailing newline whenhide_processes()first runs (it runs on everylockdownuntil the fstab line exists). - Steps (
F6_hide_processes.py, scenario 6, a fake fstab in a temporary directory):hide_processes()writes'# yescrow-ctl lockdown: …\nproc /proc proc … 0 0\n'withopen('/etc/fstab', 'a')(ops/yescrow-ctl:712–715); the result's line 3 becomesLABEL=UEFI /boot/efi vfat umask=0077 0 1# yescrow-ctl lockdown: each account sees only its own processes.systemd-fstab-generator(what the boot uses; run as a plain user on that file viaSYSTEMD_FSTAB) generates-.mountonly: libmount reportsparse error at line 3 -- ignoredandboot-efi.mountis gone. With a swap line last,swapfile.swapis gone the same way, while the control file with a newline generates it. (findmnt --verifysegfaults on such a file in util-linux 2.41.3, so it can't be the check.) - Impact: one fstab entry silently lost at the lockdown reboot; on this server plausibly the swap file the stacks rely on (
docs/runbook.md§2 "Server"). - Command:
python3 -I scripts/pentest/sixth/F6_hide_processes.py(0 failures; scenario 6 is the finding). - Root cause:
ops/yescrow-ctl:712–715(hide_processes, the append without checking that the existing content ends with\n). - Suggested fix:
prefix = '' if fstab.endswith('\n') or not fstab else '\n'before the comment; verify the result before offering the reboot (systemd-fstab-generatorinto a temp dir, refuse on "parse error"); write atomically asset_in_filedoes.
R6-F-3. The testnet path of deploy-all.sh accepts the mainnet env file: a slip puts the mainnet deployer and promo keys on cast command lines and asks forge to deploy on Sepolia with the mainnet deployer
- Severity: Info. The brief asks whether the testnet paths that still derive with
castcan run with mainnet keys: they can, nothing in the script refuses it, but only by the admin's own mistake, andhidepid(now live) hides the arguments from every other account. On-chain nothing is lost: Sepolia nonces are not Arbitrum One nonces, so the promised addresses don't move, and the deployer has no Sepolia ETH. - Attacker and preconditions: the admin, in the deploy clone of §3 with
.env.mainnetpresent, typingENV_FILE=.env.mainnet scripts/deploy-all.sh arbitrum-sepolia(the example file itself carriesENV_FILE=.env.mainneton line 5) in a shell withARBITRUM_SEPOLIA_RPC_URLexported (the vault file has none, so without it the script stops at "no RPC for arbitrum-sepolia" before any key is touched, part 3a). - Steps (
F6_forge_output_no_keys.sh, part 3, stubcast/forgelogging every argument, anvil as chain 421614): 3a without a Sepolia RPC: stops,castran 0 times. 3b with one in the shell: 5 commands, 2cast wallet address --private-keywith the env file's deployer and promo keys (lines 89 and 104), thenforge script script/DeployAll.s.sol:DeployAllon Sepolia with the mainnet file'sDEPLOYER_PRIVATE_KEY(the stub refused).YESCROW_ATTESTORin the file kept the attestor key off argv (line 96). - Impact: keys on argv for the life of two
castprocesses (hidden since the lockdown) and a testnet deployment attempt signed by the mainnet deployer, using its Sepolia nonces only. - Command:
bash scripts/pentest/sixth/F6_forge_output_no_keys.sh(part 3 prints the fact; the script's PASS is R6-F-1). - Root cause:
scripts/deploy-all.sh:53–59, 89, 104(no check that a testnet run uses a testnet env file). - Suggested fix: for a testnet network refuse an
ENV_FILEother than.envunless the caller sets an explicitTESTNET_ENV_FILE=1, or refuse an env file that setsARBITRUM_ONE_RPC_URLorCONFIRM_MAINNET; drop theENV_FILE=.env.mainnetline from.env.mainnet.example(it does nothing there and invites the slip).
Unconfirmed
- U-1. The boot-time half of
hide_processesand the logind restart.systemctl restart systemd-logindwith live sessions,systemd-remount-fsapplying the fstab's/procoptions at boot and libmount resolvinggid=procby name were reasoned, not run (root only). The live mount showsgid=982,hidepid=invisible, so the name resolution worked for the remount; whether the fstab line survives a reboot is seen at the lockdown reboot. - U-2.
secret rotate mainnet ATTESTOR_PRIVATE_KEYafter launch has no warning that the address is pinned in immutable contracts: the live attestor would sign with a key no verifier accepts (fail closed; the old key stays inretired/and §4 says how to put it back). A guard would save an outage. - U-3.
hide_processestakes a commented-out line for done (scenario 7):PROC_OPTIONS in fstabis a substring test, so an fstab where the options sit only in a comment, with/prochidden by hand, returns at once and never writes the live line; a reboot would unhide/proc. Needs a hand-made state. - U-4. What
checkKeyscan't see. It proves the vault copy's keys are the pins'; not that the attestor container runs with that value (the live proof of §4 covers it), nor that the service's promo key signs vouchers the escrows accept (nothing before opening sends a voucher; acast callof a service-signed voucher against the escrow would, cheaply). - U-5. RPC URLs with keys on
--rpc-url/--fork-url(carried from the fifth run): now hidden byhidepid; the documented URLs are public. - U-6. The
.env.mainnetcreation window in §3 step 0 (sudo cat … > .env.mainnet && chmod 600: mode 0644 under the default umask until the chmod): moot here,~adminis mode 0750 soteocan't traverse it;umask 077first on a server where it isn't.
Checked and found sound
- R5-F-1's fix, both halves.
F5_fixed_no_keys_on_command_lines.shpasses on this copy (14 commands, 69 command lines, 5 keys, 0 on a command line), and a grep ofscripts/*.sh,Makefile,docker-compose.ymland the commands ofdocs/mainnet.md§1–§5 anddocs/secrets.md§6 finds no key as an argument on the mainnet path:cast wallet decrypt-keystoreandcast wallet import --interactiveprompt, the router key is an environment assignment,sudolines carry names only (sudo logs its command lines to the journal;teois inusersonly, notadm). The server half is live:/procishidepid=invisible,gid=982, the agent sees 17 processes, all its own, andsystemd-run --userstill works under it. - forge's traces redact the keys it reads (part 1): at default verbosity and
-vvvv,VM::envUint("…")returns<env var value>,VM::addr(<pk>),VM::startBroadcast(<pk>); 0 of 19 lines carry the key. Only the parse-error path leaks, and only for escaped characters (R6-F-1). pin()ofdeploy-all.sh(part 2): forMAINNET_DEPLOYER,MAINNET_PROMO_SIGNER,YESCROW_ATTESTOR,MAINNET_FEE_RECIPIENTandMAINNET_JUDGEexactly one line matches and equals the Solidity declaration. A second match in the same shape (a doc comment) gives a two-line value thatcast balancerefuses (error: invalid value); a declaration split over two lines gives no match, whichset -ewithpipefailturns into an exit: both fail closed. A wrong promo pin is refused byDeployAll("PROMO_SIGNER is not the one in Networks.sol"); a wrong deployer pin only misdirects the balance and nonce checks, sinceDeployAllderives the deployer from the key.keyAddressand the environment (F6_KeyChecks.t.soltests 1–2): the canonical0x+64 hex, a decimal, trailing whitespace and an0Xprefix give the key's own address; quotes left in, a CR, a leading space and a bare key giveaddress(0); a bare key of digits only parses as a decimal (another address, refused); 0, n and above makevm.addrrevert outside the try, still before any send and with no key value in the message. The check can accept only the key it read or refuse, never a third address.checkKeysagainst the sends (tests 3–4, fork of Arbitrum One at the latest block): both keys wrong, one missing, one right: each refusal names the key;run()with the env of §3 stops incheck()/checkKeys()beforevm.startBroadcast, the deployer's nonce stays 0 and the promised addresses stay empty; the rehearsal's fork has chainid 42161, son.mainnetis true there and it reaches the same line. No throwaway key can satisfy a pin, so a passing mainnet run needs the vault's keys, by design;RESUME=1and--resumedon't re-run a check that already passed.- The rehearsal's throwaway router key:
cast wallet new --jsonhas the.data[0].addressand.data[0].private_keythe script reads; the key is passed as an environment assignment and through a bash here-string (a pipe in bash 5.3, no temp file), funded on the fork only, andDeployRouterrefuses the mainnet deployer (DeployRouter.s.sol:214). secret setwith its companion (ops/test-yescrow-ctl.py, 0 failures, plus an inline check): validates the key before retiring anything, strips a CR, CRLF or tab from a pasted value, writes a plainKEY=0x…line, and setsYESCROW_ATTESTORwhenever that line exists. A directsecret set mainnet YESCROW_ATTESTORis an admin setting; a value that isn't the pin is refused byDeployAll(:170) and a key that isn't the pin's bycheckKeys.hide_processes()(F6_hide_processes.pyscenarios 1–5, 7–8): the command order (getent, groupadd, daemon-reload, restart logind, try-restart polkit, remount), the two drop-ins withSupplementaryGroups=proc, the remount options, the undo on "no" (remounthidepid=off, fstab untouched; the group and drop-ins stay, harmless and root-only), a dropped connection at the prompt (an internal error,/procstays hidden until reboot, fstab untouched, a re-run completes), an existing/procline refused before any command, an existing group skipped, and a second run doing nothing. The agent account can influence none of it: the inputs are/proc/mounts,/etc/fstab, the group database and the admin's answers; the tool itself reaches the server only throughinstall's per-file diff and confirmation.- The live-proof step of
docs/mainnet.md§4:POST /v1/proofsproves for the service's configured verifier (service/src/api/app.ts:1628–1633), andproveForasks both attestors when that verifier hasyescrow()(service/src/prover.ts:79–81);DualRdapVerifier.verifydecodes two halves and verifies each with its own immutableRdapVerifier(src/DualRdapVerifier.sol:36–39); the record's.verifieris the deployed dual verifier the gate checked;.encodedis the response field and the documentedcast callsignature matchesRdapFacts. A yescrow half signed by any key but the pin revertsWrongAttestor, so the step can't pass while the attestor runs with another key, including the leaked testnet key behind a wrong URL. Networks.solagainst Arbitrum One today (F5_ArbitrumOneToday.t.sol5/5,BuildPins.t.sol1/1, re-run on the latest block): deployer nonce 0, promised addresses empty, attestor pin fresh, judge Safe 2 of 3 with the owner pin, creation-code pins equal to this build.CheckDeploymentis unchanged byd451174and still covers every immutable (fifth run).- via-IR: no contract changed; the new try/catch is in a script, compiled by the legacy pipeline (
compilation_restrictionsname only the threesrc/files), andBuildPinsties the via-IR artifacts to the pins.
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.
R6-F-1: forge prints a key in its error and trace — Verdict: CONFIRMED, Low (wider than reported)
Verdict: Confirmed, at Low. The finding is real but wider than the report says, several of its details are wrong, and the Solidity half of its suggested fix does not work.
Severity: Low (unchanged). The proof passes. I also reproduced the leak through the real scripts/deploy-all.sh in rehearsal mode, so it can't be dropped. It stays Low rather than higher because none of the documented ways of writing a key line can produce a malformed one:
- §3 step 0 appends the clean hex that cast wallet decrypt-keystore prints (docs/mainnet.md:127).
- secret gen writes clean hex (ops/yescrow-ctl:363–365).
- secret set strips leading and trailing whitespace, including CR, tab and the non-ASCII space U+00A0 (ops/yescrow-ctl:585), and fully checks the attestor key's format (:589).
So a leak needs a hand edit (sudoedit on the vault, or an editor on the deploy clone's .env.mainnet). Even then the key only reaches the admin's own SSH terminal. Sharing that output with Claude is in no procedure, and docs/secrets.md:85 forbids pasting keys; the key is plainly visible in the message. This is narrower than R5-F-1 (Low: every local account, every rehearsal). It stays Low rather than Info because the keys exposed are wider than the report says: the deployer, the relayer, the attestor and the promo key. A leaked attestor or deployer key before launch means new pins and another audit round; a leaked promo key costs fees for up to 92 days. Also, a pasted deployer key would never be flagged: claude-guard only runs on tool calls, not on what the user types (ops/managed-settings.json), and the guard and the nightly scan only know keys still in the vault (ops/yescrow-ctl:154–162, 203–220). After secret rm mainnet DEPLOYER_PRIVATE_KEY (docs/secrets.md:105), the deployer key is no longer there.
Proof result
The given command, FOUNDRY_DYNAMIC_TEST_LINKING=false systemd-run --user --scope -p MemoryMax=3300M -p MemorySwapMax=3G --quiet -- bash scripts/pentest/sixth/F6_forge_output_no_keys.sh, printed PASS: R6-F-1 reproduced …:
- Leaks: for both unparsable() and unparsableOr(), a value with a CR, a tab followed by text, or a U+00A0 put the whole key in 3 lines.
- No leak: a stray Z, a quoted key and two keys printed failed parsing $F6_BAD.
- Successful reads: the traces were clean at default verbosity and at -vvvv.
My extra runs used throwaway keys and a plain local anvil with chain id 42161 (nothing left the machine):
| Run | Key value | Lines with the whole key |
|---|---|---|
Real deploy-all.sh arbitrum-one, rehearsal mode |
CR on the DEPLOYER_PRIVATE_KEY line |
3 |
| Same | U+00A0 at the end | 3 |
| Same | quoted value with a tab inside | 3 |
| Same | trailing unquoted tab (bash drops it, the run went on) | 0 |
Real DeployRouter.s.sol |
ROUTER_DEPLOYER_PRIVATE_KEY + CR |
3 |
| Same | the DEPLOYER_PRIVATE_KEY fallback + CR |
3 |
Real DeployAll.checkKeys (through the agent's F6Harness) |
ATTESTOR_PRIVATE_KEY + CR |
1, a trace line VM::envUint("ATTESTOR_PRIVATE_KEY") … ← [Revert] vm.envUint: failed parsing "0x<64hex>\r", then the refusal |
| Value passed straight to forge | trailing tab or space | 0 (both parse as the key) |
| Same | leading U+00A0, or CR then a space | 3 |
cast wallet address --private-key |
malformed key | 0: it prints only "Failed to decode private key" |
Corrections to the finding
- Attestor and promo keys: the report says they "are not affected" because the try/catch in checkKeys "prints nothing". That is wrong. The catch in keyAddress (contracts/script/DeployAll.s.sol:217–223) does swallow the error, but checkKeys (:205–214) then refuses, the script fails, and forge 1.8.5 prints a failing script's traces at default verbosity (no verbosity setting in foundry.toml). Those traces include the cheatcode call that failed inside the try, with the key. I proved this for the attestor key with the real code. The promo key goes through the same keyAddress; with throwaway keys the run can't get past the attestor check to reach it, so I showed it with an identical stand-in.
- Wrong line numbers: DeployRouter.s.sol:212–213 is really :43–44, and :214 is :45 (the file has 54 lines). The same wrong :212 is in the proof script (scripts/pentest/sixth/F6_forge_output_no_keys.sh:36) and in the stand-in's comment (F6_KeyEcho.s.sol:25).
- DeployRouter doesn't read the deployer key at launch: in the documented launch, ROUTER_DEPLOYER_PRIVATE_KEY is the relayer key (docs/mainnet.md:135); in the rehearsal it is a throwaway (scripts/rehearse-mainnet.sh:34). The deployer fallback at :44 is never reached by a documented command. The keys exposed are:
- the deployer, read at DeployAll.s.sol:74 (this citation is correct);
- the relayer, at DeployRouter.s.sol:43, in §3 step 1;
- the attestor and promo keys, at DeployAll.s.sol:218.
- Tab: a trailing tab parses as the key and does not leak. Only a tab followed by more text leaks, and through set -a; . file (deploy-all.sh:43, docs/mainnet.md:132) only inside quotes. Unquoted, bash runs the rest as a command and set -e stops the script. line() in yescrow-ctl (ops/yescrow-ctl:99–104) does write such quotes for any value that isn't plain.
- "Three lines of the message": two are trace lines (the cheatcode's revert and the function's revert) and the third is Error: script failed:. "Traces … are clean" is true only for reads that succeed.
- What secret set strips: more than CR and tab. Python's .strip() (ops/yescrow-ctl:585) removes all leading and trailing Unicode whitespace, U+00A0 included. Characters inside the value stay, and only ATTESTOR_PRIVATE_KEY gets a format check (:589).
- Where the deployer line comes from: after the keystore import, the vault holds no DEPLOYER_PRIVATE_KEY (docs/secrets.md:105). For the deployer, the only path is a hand edit of the clone's .env.mainnet, not sudoedit on the vault.
- Where the output goes: only the admin's terminal. "Checks the results" (docs/mainnet.md:10) refers to the deployments and broadcast records copied over at the end of §3, not to pasted terminal output.
- I didn't check map_env_err against foundry's source. The output fits the explanation: the plain spelling is replaced by $F6_BAD, and the escaped spelling is not.
Fix assessment
- The Solidity half doesn't work. Wrapping the read in a keyAddress-style try/catch followed by a require still leaks one line at default, -vv and -vvv (V6F1TryCatch.fixedShape). Any cheatcode that fails while holding the value puts it in the trace of a failing run, and the require then makes the run fail. Today's checkKeys leaks the same way.
- Don't use vm.parseUint either. Reading the value as a string and parsing it with vm.parseUint prints the whole key in the trace even when the key is clean; parseUint's argument is not redacted (V6F1StringRead).
- What works:
- (a) A shell check after sourcing, before any forge call, that never prints the value. For example: [[ ${!v} =~ ^0x[0-9a-fA-F]{64}$ ]] for each of DEPLOYER_PRIVATE_KEY, ATTESTOR_PRIVATE_KEY, PROMO_SIGNER_PRIVATE_KEY and RELAYER_PRIVATE_KEY that is set. It belongs in deploy-all.sh after line 43, which also covers the rehearsal and the R6-F-3 testnet slip. §3 step 1 runs DeployRouter by hand outside the script, so it needs the same check or should become a script.
- (b) Or in Solidity: read with vm.envOr(name, string("")), whose return is redacted in the trace. Check 0x plus 64 hex digits and convert to a number in plain Solidity, with no cheatcode, then call vm.addr. I didn't test (b) end to end.
- The report's grep prints the key. grep -nP '^[A-Z_]*_PRIVATE_KEY=.*[^\x20-\x7e]' as written shows the matching line, which is the key, on the screen. It needs -q or -c. A positive pattern on the sourced variables is also safer than a negative pattern on file lines, which misses export and indented lines.
- Other places checked:
- cast doesn't echo a malformed key.
- vm.addr and vm.startBroadcast are redacted.
- Deploy.s.sol:46 and DeployTestUSDT.s.sol:12 have the same unwrapped read but are testnet-only.
- Reporting it upstream is reasonable; mention the trace path as well as the error message.
Files I added: my stand-in, <scratchpad>/pentest6/F/contracts/test/pentest/sixth/V6_F1_TryCatch.s.sol (contracts V6F1TryCatch and V6F1StringRead), and my scripts in <scratchpad>/pentest6/F1-verify/ (show.sh, e2e.sh, trycatch.sh, realcheck.sh, strread.sh, forms.sh). I left them in place.
R6-F-2: lockdown's /etc/fstab append — Verdict: CONFIRMED, Low
Verdict: confirmed, at Low (the severity the report claims).
Severity: Low. The proof passes and shows the claimed effect. No attacker is involved, no money is at risk and no secret leaks: it is root's own tool losing one fstab entry, silently, at the reboot lockdown asks for right after (ops/yescrow-ctl:677–678). The precondition is narrow. Ubuntu cloud images, the installer and cloud-init all end fstab with a newline, and so do echo … | tee -a, nano 8.7.1 (unless run with -L/--nonewlines) and vim (its default fixeol). Even … 0 0 with a trailing space survives. It takes printf, echo -n, a script or an editor set up not to add the newline. On this server the precondition was false, and :693 now returns at once, so the step can't append again. I don't raise it for the worst variant (root entry last, below), because the admin is at the console for the reboot and it can be repaired by hand. I don't lower it to Info, because the docs tell the admin to re-run lockdown (docs/secrets.md:56, docs/mainnet.md:92), the tool is meant for any server, and nothing is logged when the entry is lost.
Proof result: python3 -I scripts/pentest/sixth/F6_hide_processes.py from the copy's root gives 0 failure(s), exit 0 (Python 3.14.4, systemd 259.5, util-linux 2.41.3).
- Scenario 6 writes …umask=0077 0 1# yescrow-ctl lockdown: ….
- The generator then produces only -.mount; boot-efi.mount is lost.
- With a swap line last, it produces -.mount boot-efi.mount, so swapfile.swap is lost.
- The control file with a newline does generate swapfile.swap.
My own runs (in <scratchpad>/verify-R6F2/, through harness.py and the generator):
- This server's layout with /swapfile2 last loses only swapfile2.swap.
- A root line last loses -.mount.
- Last lines with 4 or 5 fields are dropped too.
- A trailing space or tab, a comment as the last line, and CRLF endings are all harmless.
Corrections to the finding:
- "libmount reports parse error at line 3 -- ignored" (R6-F-2 Steps) is wrong for the generator. systemd-fstab-generator says nothing about the dropped line, even at SYSTEMD_LOG_LEVEL=debug. It logs a "Found entry" line for each surviving entry and nothing for the lost one. That message is findmnt's, printed just before it segfaults (exit 139, with --verify and with a plain --tab-file). The proof's own output shows no message either (the script's line 185 prints none). So the boot leaves no trace in the journal, which strengthens "silently".
- "Not a lockout: the root entry is read" holds only when root isn't the last line. If the root entry is last with no newline, the generator writes no -.mount. systemd-remount-fs then finds no root entry; its man page says it "executes no operation" in that case. This server boots with ro on its kernel command line (/proc/cmdline). So root would most likely stay read-only after the boot, and Docker and the stacks would not come up until someone repairs it by hand. I reasoned this, I did not boot it. sshd and sudo work from /run, so it is still not a lockout.
- "This VPS added an 8 GB swap file by hand" is wrong. docs/runbook.md:217–218 lists zram (from zram-generator, not fstab) plus /swapfile and /swapfile2, 4 GB each. With that layout, only /swapfile2 would be lost; zram and /swapfile would stay up.
- "Precondition unverifiable" is now settled. The real fstab ended with a newline, nothing was lost, and ops/yescrow-ctl:693 keeps the step from running again.
- The cited lines are right: ops/yescrow-ctl:692 (read), :712–715 (the append), :693, :695 and :712 (the substring tests), :716 (the "kept" message).
Fix assessment:
- The newline guard works. I patched a copy (<scratchpad>/verify-R6F2/fix1/ops/yescrow-ctl) with the report's prefix line. Scenario 6 then flips: boot-efi.mount and swapfile.swap are generated again. My variants (/swapfile2 last, root only) also keep all their units. The check should read /etc/fstab again just before the append: the read at :692 happens before the remount and before the admin opens a second login.
- "Refuse on parse error" from the generator would never trigger. The generator is silent about dropped lines. A check that works is to generate units from the old and the new file and require the same set; the /proc line adds no unit, since /proc is an API file system. findmnt --verify crashes on the bad file, so it can't be the check.
- "Write atomically as set_in_file does" needs care. set_in_file creates its temp file with mode 0o600 (ops/yescrow-ctl:128). Copied as it is, it would make /etc/fstab readable by root only; it needs 0o644 and root:root. An append with the guard is enough.
- The fix doesn't cover U-3, and U-3 is broader than the report says. Scenario 7 passes. I also reproduced a more likely variant. The admin comments out the tool's own line (to undo it) and reboots, so /proc is no longer hidden. On the next lockdown, the tool remounts /proc, writes no fstab line (:712 finds the substring in the comment), and prints "kept: /etc/fstab mounts /proc that way at every boot" (:716). That message is false, and /proc is unhidden at the next boot. A live proc /proc proc defaults 0 0 line plus the options in a comment also gets past the die guard at :695, with the same false "kept". I'd move U-3 into the findings at Low or Info: hidepid is defence in depth now that d451174 keeps keys off command lines.
- A complete fix uses one line-aware check at :693, :695 and :712.
- Skip comment lines; take normpath(fields[1]) == '/proc', and require the options split on , to include hidepid=invisible and gid=proc.
- Stop with an error if any live /proc entry lacks them, and append only when there is no live /proc entry.
- This also covers proc /proc/ proc …, which the regex at :695 misses. The tool would then append a second /proc line; I didn't establish which of the two mount(8) applies at boot.
- No other lockout path in hide_processes. Logins and sudo don't need to see other accounts' processes, systemd-logind and polkit get the proc group through their drop-ins, and the "no" answer and a dropped connection are handled (scenarios 3 and 4).
R6-F-3: the testnet path and the mainnet env file — Verdict: CONFIRMED, Info
Verdict: confirmed at Info.
Severity: Info. I agree with the attack agent. Under the rubric no party's money, deadline or fee is at stake, and the mistake takes two of the admin's own deviations from the documented procedure. The admin has to run the testnet network in the mainnet deploy clone, and a Sepolia RPC has to be in a shell that §3 says to start clean (env -i HOME PATH bash, docs/mainnet.md:118). No step tells the admin to run any Sepolia script there; testnet deploys run as teo with .env. With hidepid=invisible live, only root and the admin's own processes could read the two cast command lines. That protection holds only if the fstab line survives a reboot, which this run left unconfirmed (U-1, U-3, R6-F-2). It does not raise the severity, because the admin's mistake comes first. On-chain, a Sepolia deployment by the mainnet deployer cannot touch Arbitrum One:
- Nonces are kept per chain.
- Forge's signatures bind chain id 421614, so nothing replays on Arbitrum One.
- deploy-all.sh:87 checks the chain id before any key is used (:89).
- DeployAll's mainnet guards read Arbitrum One's state (DeployAll.s.sol:168).
- §3 copies only the 42161 records (docs/mainnet.md:170).
Proof result
- Part 3 run on its own and verbatim: bash <scratchpad>/pentest6/F/scripts/pentest/sixth/verify/F6_part3_verbatim.sh. It is lines 1–21 and 70–101 of the original; part 1 was not run, so forge never ran.
- (a) With no Sepolia RPC it stops with no RPC for arbitrum-sepolia in .env.mainnet, and cast ran 0 times.
- (b) With the Sepolia RPC in the shell it runs 5 commands: chain-id, 2 × wallet address --private-key, forge script script/DeployAll.s.sol:DeployAll --rpc-url … --broadcast --slow, then cast nonce. The attestor key stayed off the command line. Exit 0. Reproduced.
- My extended copy: <scratchpad>/pentest6/F/scripts/pentest/sixth/verify/R6F3_part3.sh. Its forge stub also records forge's environment.
- Case b, matched by key: 1 deployer key and 1 promo key on a command line, 0 attestor keys. Forge's environment held the mainnet file's DEPLOYER_PRIVATE_KEY, with YESCROW_ATTESTOR set to the pin 0x2164…82b8 and JUDGE to the Safe. This confirms the "mainnet deployer" claim, which the original stub could not show.
- Case c: the shell as it is after §3 step 1 (set -a; . ./.env.mainnet), with ENV_FILE not typed. Same result.
- Case d (control: arbitrum-one on the same file): 0 keys on a command line.
- Read-only chain check of the mainnet deployer 0xA8C2…D14D on 2026-11-06: balance 0 and nonce 0 on both 421614 and 42161. The promised escrow addresses have no code on Sepolia.
Corrections to the finding
- The precondition is wider than stated. Typing ENV_FILE is not needed. docs/mainnet.md:132 (§3 step 1, set -a; . ./.env.mainnet; set +a) exports the file's ENV_FILE=.env.mainnet (.env.mainnet.example:5) into the admin's shell. After step 1, a plain scripts/deploy-all.sh arbitrum-sepolia reads the mainnet file (case c). A Sepolia RPC is still needed.
- "The vault file has none" was not checked. The vault is root-only, so only the template (.env.mainnet.example) is known to lack ARBITRUM_SEPOLIA_RPC_URL. If the vault was ever seeded from a testnet .env (.env.example:6 sets it), the wrong network name alone would be enough.
- ".env.mainnet.example:5 does nothing there" is wrong. docker-compose.yml:73 fills in ${ENV_FILE:-.env} for the containers' env file, and the template's own command at .env.mainnet.example:3 relies on it. yescrow-ctl now sets ENV_FILE itself (ops/yescrow-ctl:250). The line also feeds the shell export in the point above.
- The root cause stops at deploy-all.sh, but other testnet scripts that accept ENV_FILE do the same:
- scripts/deploy-escrow.sh:30 puts ATTESTOR_PRIVATE_KEY on the command line before the RPC check at :32, and it ignores YESCROW_ATTESTOR. Reproduced with no Sepolia RPC: 1 attestor key on a command line, then exit at :32 (<scratchpad>/pentest6/F/scripts/pentest/sixth/verify/R6F3_other_scripts.sh). It also puts the promo key on the command line at :38.
- scripts/fund-testnet-wallets.sh:11,15,21,23 puts the relayer key and the deployer key on the command line. :15 and :23 are cast send … --private-key "$DEPLOYER_PRIVATE_KEY" on Sepolia. This needs the Sepolia RPC (:9); I read it from the code and did not run it with an RPC.
- scripts/deploy.sh:8–9,16 reads the env file the same way.
- A second root cause is missing. DeployAll.s.sol:121–150 does not refuse Networks.MAINNET_DEPLOYER on chains other than mainnet, while DeployRouter.s.sol:45 refuses it on every chain. That gap is why the real forge would deploy on Sepolia.
- "The deployer has no Sepolia ETH" is true today but protects nothing. Anyone can fund that address on Sepolia from a faucet. The slip would then deploy at Sepolia nonces 0–4, which are the mainnet-promised addresses (0xe13E…aBa, 0x3A73…fbB), with mainnet's judge, fee recipient, attestor pin and promo signer. This can confuse readers of explorers or the clone's deployments/arbitrum-sepolia.json, but does nothing on Arbitrum One.
- The deployer half needs the §3 session's file. After the keystore import, the vault no longer holds DEPLOYER_PRIVATE_KEY (docs/secrets.md:105), so outside the session that starts at docs/mainnet.md:127 the script stops at deploy-all.sh:88, before :89.
- Label error: F6_forge_output_no_keys.sh:13–14 calls the testnet-path result "R6-F-1, Info"; it is R6-F-3.
- Aside, not this finding: the report cites DeployRouter.s.sol:212–213 and :214 (R6-F-1 and the "checked and found sound" list), but the file has 54 lines. The read is at :43–44 and the refusal at :45.
Fix assessment
- "Refuse an ENV_FILE other than .env on a testnet unless TESTNET_ENV_FILE=1" works for cases b and c if it runs before deploy-all.sh:43. It is incomplete: it covers deploy-all.sh only, not deploy-escrow.sh, deploy.sh or fund-testnet-wallets.sh, and it misses a mainnet file copied as .env.
- The alternative "refuse an env file that sets ARBITRUM_ONE_RPC_URL or CONFIRM_MAINNET" is flawed both ways:
- The testnet .env is documented to carry ARBITRUM_ONE_RPC_URL for the mainnet fork tests (.env.example:9–10; contracts/test/fork/*), so this would block real testnet deploys.
- CONFIRM_MAINNET is taken only from the caller (deploy-all.sh:31,49) and is never in the file, so checking for it catches nothing.
- DEPLOYMENT=arbitrum-one (.env.mainnet.example:6, versus arbitrum-sepolia at .env.example:83) or NEXT_PUBLIC_CHAIN_ID=42161 would work as markers.
- Dropping line 5 from the template narrows case c. It does not change the live vault file, which step 0 copies; that needs an edit there too. Fix the template's stale line-3 command at the same time. A cleaner option is to run §3 step 1 in a subshell, ( set -a; . ./.env.mainnet; set +a; cd contracts; … ), so neither the keys nor ENV_FILE stay in the admin's shell.
- A complete fix needs two more changes:
- In DeployAll's check(): if (!n.mainnet) require(deployer != Networks.MAINNET_DEPLOYER, …), mirroring DeployRouter.s.sol:45, and the same in Deploy.s.sol. This stops the Sepolia deployment inside forge, whichever script calls it.
- In the testnet scripts: stop deriving addresses with cast wallet address --private-key. cast 1.8.5 has no environment variable for --private-key (cast wallet address --help), so derive them inside forge (vm.addr of vm.envOr keys), as mainnet already does. That removes the command-line half for any env file and meets docs/secrets.md:89. The env-file guard then becomes a second layer of defence.