Attack packs
policy-bypass
Targets OpenZeppelin stellar-contracts 0.8.0 smart accounts. Built-in invariants:
RollingCap: cumulative spend of an actor within a rolling ledger window never exceeds the limit the operator meant (fires in the demo, seed 7)AllowlistOnly: an actor only ever succeeds with allow-listed calls (holds in the demo)RevokedNeverAuths: once revoked, a signer never authorizes a call again (holds in the demo)PinnedWasm: the policy and verifier executables pinned at setup are the ones in place at every step (holds in the demo)
Generators bias toward bypass shapes (amounts just under a cap, ledger gaps that straddle a policy window). Every step runs through the host’s real authorization path (AuthMode::Real): the pack builds the SorobanAuthorizationEntry values an OpenZeppelin account expects, including the nested entry for a delegated signer, so a finding is a real bypass, not an artifact of mocked auth.
The bundled demo (examples/policy-bypass) is an OpenZeppelin smart account with no changes besides a constructor that installs the initial context rules; its agent rule carries two SpendingLimit policies: 50 USDC per ledger and 100 USDC per “day”, where the day was configured as 1 728 ledgers instead of 17 280. SpendingLimit counts its window in ledgers, and dividing 86 400 seconds by 50 instead of by 5 is a one-character mistake. The suite finds the three-payment bypass shown above and shrinks it to the window boundary.
ttl-archival
Advances the ledger according to a usage profile (LedgerAdvance::daily_then_silence(30, 90): one call a day for a month, then a quiet quarter) and asserts:
NoMidLifecycleExpiry: no persistent entry a running stream depends on runs out of TTL (fires in the demo, seed 11)InstanceAlive: the contract instance stays alive while any stream is running (holds in the demo until the run stops at the first violation, seed 11)RestoreRestoresFunction: after an archived entry is restored,withdrawandcancelstill work (checked on a throwaway copy of the ledger, so the probe never disturbs the run; holds in the demo until the run stops at the first violation, seed 11)NoUnboundedTtlExtension: no entry is kept alive beyond what its stream needs; catches a public, unauthenticatedextend_ttlentry point used to park data at the contract’s rent (fires in the demo, seed 5)RestoreCostWithinBudget: a call made while entries are archived stays within a resource budget (instructions, memory, disk read and write entries and bytes, from the host’s metering), so restoration cannot push a transaction over the network limits (fires in the demo, seed 3)
What expiry means on Soroban since Protocol 23: an archived persistent or instance entry is not lost, and a call that touches it does not simply fail. The next transaction that touches an archived entry must restore it (simulation adds the entry to the transaction’s restore list) and pay for the restoration, a transaction built without the restore fails, and a contract that never extends TTL sees the entry archived again after every quiet period of at least the minimum TTL. The pack therefore reads each entry’s live_until directly from the ledger entries, because any contract read, and the SDK’s own get_ttl test helper, would restore the entry and hide the finding.
The bundled demo (examples/ttl-archival) is a payment stream that keeps streams in persistent storage and never calls extend_ttl. Under the profile above, a one-year stream is archived after the quiet quarter with 275 days left to run; PaymentStreamFixed, which extends every touched entry to the stream’s remaining duration, survives the same profile. Two more suites in examples/ttl-archival/tests/ drive the other invariants: with seed 5, NoUnboundedTtlExtension shrinks to 3 steps in which anyone’s call to the unauthenticated bump_ttl keeps a stream alive 1.3 days past its end; with seed 3, RestoreCostWithinBudget shrinks to 4 steps in which one withdraw_many after a quiet quarter reads 5 entries from disk against a budget of 3. That 3 is a threshold the suite sets, not a network limit.