Agent pipeline
| agent | role | files | findings | ms |
|---|---|---|---|---|
evm-hunter#1 | scan shard (parallel) | 1 | - | 214 |
solana-hunter#1 | scan shard (parallel) | 1 | - | 21 |
evm-hunter#2 | scan shard (parallel) | 1 | 10 | 513 |
evm-hunter#3 | scan shard (parallel) | 1 | 14 | 437 |
solana-hunter#2 | scan shard (parallel) | 1 | 25 | 397 |
adjudicator | cross-check / corroboration | - | 49 | 0 |
report-writer | output-contract gate | - | 49 | 0 |
The adjudicator records where independent detectors agree; it is annotation-only and cannot change a severity or confidence.
Ground truth
| seeded issue | caught | rule | detector | severity |
|---|---|---|---|---|
evm-reentrancy-withdraw | yes | CEI-001 | evm_reentrancy | critical / critical |
evm-access-adminDrain | yes | AC-001 | evm_access | critical / critical |
evm-tx-origin-setOperator | yes | AC-002 | evm_access | high / high |
evm-unchecked-call-refundPending | yes | EC-001 | evm_external_calls | high / high |
evm-spot-price-assetValue | yes | OR-001 | evm_oracle | critical / critical |
hyperevm-delegatecall-migrate | yes | DC-001 | evm_delegatecall | critical / critical |
hyperevm-sig-replay-claimTreasuryGrant | yes | SR-002 | evm_sig_replay | critical / critical |
hyperevm-selfdestruct-decommission | yes | SD-001 | evm_selfdestruct | critical / critical |
hyperevm-stuck-funds-recoverTokens | yes | SF-001 | evm_selfdestruct | high / high |
hyperevm-unguarded-logic-pointer | yes | DC-002 | evm_delegatecall | high / high |
solana-missing-signer-withdraw | yes | SIGNER-002 | solana_signer | critical / critical |
solana-token-cpi-unvalidated-deposit | yes | ACCT-002 | solana_accounts | critical / critical |
solana-account-confusion-deposit | yes | ACCT-001 | solana_accounts | high / high |
solana-unchecked-cpi-settle_external | yes | CPI-001 | solana_cpi | high / high |
solana-precision-loss-deposit | yes | MATH-001 | solana_math | high / high |
solana-unsafe-deser-load_foreign_config | yes | DESER-001 | solana_deser | critical / critical |
solana-remaining-accounts-settle_many | yes | REMOT-001 | solana_cpi | medium / medium |
Ranked findings
Detail
critical delegatecall to unvalidated target in migrate() solidity/HyperVault.sol:54 (migrate) conf 0.92 · DC-001 · hyperevm
migrate() executes `target.delegatecall(...)`. the delegatecall target `target` is an instruction argument, so any caller chooses the code that runs inside this contract's storage. delegatecall keeps this contract's storage and msg.sender, so the callee can rewrite owner/admin/implementation slots, mint, drain, or call selfdestruct. Authorisation on the wrapper (absent) does not help if the target itself is attacker-chosen.
(bool ok, ) = target.delegatecall(data);
- Deploy a malicious implementation contract.
- Call migrate() with your contract as the target (or first move the pointer via the unguarded setter).
- Inside the callee, write the admin/owner slot, then use the admin path to take the funds.
- Only delegatecall to a constant, admin-upgraded-with-timelock implementation address.
- Add EIP-1967 slots + an `onlyProxyAdmin` + timelock upgrade path.
- Validate `target.code.length > 0` and that the target is in an allowlist.
PoC stub: pocs/sg-evm-001_sg_evm_001_delegatecall_to_unval.t.sol
run: forge test --match-contract SgEvm001DelegatecallToUnvalidatedTargetIHarness -vvvv
skill skills/delegatecall-proxy/SKILL.md checklist satisfied:
- The delegatecall target resolves to a constant or admin-upgraded implementation address -
- Writes to logic pointers are guarded by an admin role **and** a timelock/delay, and emit an
critical Unsafe cast of account data into `VaultConfig` in load_foreign_config() solana/vault/programs/vault/src/lib.rs:117 (load_foreign_config) conf 0.90 · DESER-001 · solana
load_foreign_config() reinterprets borrowed account bytes as `*const VaultConfig` and dereferences it. The layout of an account is entirely caller-supplied: there is no owner check and no length/discriminator check here, so the program reads memory it never validated. That is a type-conflict / out-of-bounds read on a chain where the runtime will not save you - and even when it only reads garbage, the value becomes trusted state.
corroborated independently by 1 other detector(s), 1 rule(s): ACCT-001 (SG-SOL-014) (annotation only — does not change this grade)
let cfg: &VaultConfig = unsafe { &*(data.as_ptr() as *const VaultConfig) };
- Create an account with the program's own PDA-looking address (or any short account) and pass it in.
- The cast reads whatever bytes exist - attacker-chosen numbers become `limiter`, authority, etc.
- Use the forged field to unlock the privileged branch of the handler.
- Use Anchor's typed account (`Account<'info, VaultConfig>`), which verifies owner + discriminator.
- If raw access is unavoidable: check `account.owner == &id()`, then `VaultConfig::try_deserialize(&mut &data[8..])` and never dereference a cast.
- Add `require!(data.len() >= 8 + size_of::<VaultConfig>(), ...)` before reading.
PoC stub: pocs/sg-sol-001_sg_sol_001_unsafe_cast_of_accoun.rs
run: cargo test --features test-suites sg_sol_001_unsafe_cast_of_account_data_into_vaul
skill skills/solana-account-validation/SKILL.md checklist satisfied:
- SPL accounts assert `owner == token_program`, the right `mint`, and the expected authority
- `remaining_accounts` are length-checked **before** indexing and validated individually -
critical No spend-once tracking: claimTreasuryGrant() can be replayed forever solidity/HyperVault.sol:87 (claimTreasuryGrant) conf 0.90 · SR-002 · hyperevm
claimTreasuryGrant() pays out on a verified signature but no nonce, no mapping write and no already-used check exists anywhere in the contract. The signer's single authorisation is therefore an unlimited mint: the same (v, r, s) can be submitted in an unbounded number of transactions by anyone who has seen it once - including from public mempool history.
corroborated independently by 2 other detector(s), 3 rule(s): CEI-001 (SG-EVM-003), EC-001 (SG-EVM-010), EC-003 (SG-EVM-024) (annotation only — does not change this grade)
function claimTreasuryGrant(bytes32 doc, uint8 v, bytes32 r, bytes32 s, uint256 amount) external {
- Observe one legitimate use of claimTreasuryGrant() (mempool, front-end, an airdrop claim).
- Call it again with the identical doc/v/r/s - the require() on the recovered address still passes.
- Repeat until the contract is empty; gas is the only cost.
- Track spend: `require(!used[hash]); used[hash] = true;` keyed on the full digest.
- Or carry a per-signer monotonic nonce inside the signed payload and compare it to storage.
PoC stub: pocs/sg-evm-002_sg_evm_002_no_spend_once_trackin.t.sol
run: forge test --match-contract SgEvm002NoSpendOnceTrackingClaimtreasuryHarness -vvvv
skill skills/signature-replay/SKILL.md checklist satisfied:
- A spend-once record exists (per-hash mapping or per-signer nonce) and is written in the same
- Batch payloads bind the whole payload hash (no partial-subset replay).
critical Reentrancy: state updated after external call in claimTreasuryGrant() solidity/HyperVault.sol:90 (claimTreasuryGrant) conf 0.90 · CEI-001 · hyperevm
claimTreasuryGrant() performs an external interaction (payable(msg.sender).transfer) at line 90 and only updates contract state afterwards (borrowed). A callee can re-enter claimTreasuryGrant() during that callback and still see the pre-call balance, so the same funds can be withdrawn repeatedly. No reentrancy guard and no checks-effects-interactions ordering were found in this function.
corroborated independently by 2 other detector(s), 6 rule(s): EC-001 (SG-EVM-010), EC-003 (SG-EVM-024), SR-001 (SG-EVM-017), SR-002 (SG-EVM-002), SR-003 (SG-EVM-020), SR-004 (SG-EVM-023) (annotation only — does not change this grade)
payable(msg.sender).transfer(amount);
- Deploy a malicious callee whose fallback/receive() calls back into claimTreasuryGrant().
- On the first call, pass an amount the contract still thinks you are owed.
- Inside the callback, call claimTreasuryGrant() again before the balance subtraction at line 91 has executed - the old balance is still readable.
- Repeat until the contract's native/ERC20 balance is drained.
- Update all accounting before the external call (checks-effects-interactions).
- Or add OpenZeppelin ReentrancyGuard: `nonReentrant` on claimTreasuryGrant().
- Prefer push payments with an explicit withdraw-then-send ordering.
PoC stub: pocs/sg-evm-003_sg_evm_003_reentrancy_state_upda.t.sol
run: forge test --match-contract SgEvm003ReentrancyStateUpdatedAfterExterHarness -vvvv
skill skills/reentrancy/SKILL.md checklist satisfied:
- State writes (`balances[...]`, totals, flags, allowances, nonces) are ordered strictly
- A reentrancy guard (`nonReentrant`, OZ `ReentrancyGuard`, ERC-7201 transient storage
- before** the interaction - checks-effects-interactions holds.
- Callbacks from the callee (`onERC721Received`, `tokensReceived`, `ERC4626` deposit hooks,
critical Reachable `selfdestruct` with caller-chosen beneficiary in decommission() solidity/HyperVault.sol:102 (decommission) conf 0.90 · SD-001 · hyperevm
decommission() can destroy IHyperCorePriceOracle and force-send the whole native balance to an address the caller picks (`beneficiary`). There is no authorisation check at all on this path. Destructible state also kills any index that assumed code persists, and it makes an irreversible action available without a timelock or governance delay.
selfdestruct(beneficiary);
- Call decommission() (directly, or through the unguarded entry that reaches it).
- The contract's storage is wiped and its ETH balance is pushed to the chosen beneficiary.
- Every user balance recorded in the contract becomes unrecoverable.
- Remove selfdestruct from application code; use a pause flag plus an explicit withdrawal route.
- If a migration really is needed, gate it behind a timelock + multi-sig governance role and a fixed beneficiary.
- Note that EIP-678 (Cancun and later) limits selfdestruct to same-transaction creation, but the storage/balance sweep still applies on pre-Pectra deployments and on HyperEVM.
PoC stub: pocs/sg-evm-004_sg_evm_004_reachable_selfdestruc.t.sol
run: forge test --match-contract SgEvm004ReachableSelfdestructWithCallerCHarness -vvvv
skill skills/fund-recovery/SKILL.md checklist satisfied:
- `selfdestruct` does not exist in application code. If it does, it is reachable only behind a
critical Reentrancy: state updated after external call in withdraw() solidity/Vault.sol:74 (withdraw) conf 0.90 · CEI-001 · evm
withdraw() performs an external interaction (msg.sender.call) at line 74 and only updates contract state afterwards (balanceOf[msg.sender], totalDeposits). A callee can re-enter withdraw() during that callback and still see the pre-call balance, so the same funds can be withdrawn repeatedly. No reentrancy guard and no checks-effects-interactions ordering were found in this function.
(bool ok, ) = msg.sender.call{value: amount}("");
- Deploy a malicious callee whose fallback/receive() calls back into withdraw().
- On the first call, pass an amount the contract still thinks you are owed.
- Inside the callback, call withdraw() again before the balance subtraction at line 76 has executed - the old balance is still readable.
- Repeat until the contract's native/ERC20 balance is drained.
- Update all accounting before the external call (checks-effects-interactions).
- Or add OpenZeppelin ReentrancyGuard: `nonReentrant` on withdraw().
- Prefer push payments with an explicit withdraw-then-send ordering.
PoC stub: pocs/sg-evm-005_sg_evm_005_reentrancy_state_upda.t.sol
run: forge test --match-contract SgEvm005ReentrancyStateUpdatedAfterExterHarness -vvvv
skill skills/reentrancy/SKILL.md checklist satisfied:
- State writes (`balances[...]`, totals, flags, allowances, nonces) are ordered strictly
- A reentrancy guard (`nonReentrant`, OZ `ReentrancyGuard`, ERC-7201 transient storage
- before** the interaction - checks-effects-interactions holds.
- Callbacks from the callee (`onERC721Received`, `tokensReceived`, `ERC4626` deposit hooks,
critical Missing access control on adminDrain() solidity/Vault.sol:83 (adminDrain) conf 0.90 · AC-001 · evm
adminDrain() is external, moves value to `to`, and writes payout, yet nothing in the function proves who the caller is: no `only*` modifier, no `require(msg.sender == ...)`, no role check, and no caller-scoped accounting. So the state it touches is not limited to the caller's own balance - any address can call it. This is a total-loss path: the destination is fully caller-chosen.
corroborated independently by 2 other detector(s), 2 rule(s): CEI-001 (SG-EVM-007), SD-002 (SG-EVM-009) (annotation only — does not change this grade)
function adminDrain(address to, uint256 amount) external {
- Call adminDrain() from an EOA you control, with the arguments you want.
- No key compromise or front-running is required - the entry point is permissionless.
- Repeat until the shared balance is gone (or until every victim's claim has been consumed).
- Add `onlyOwner` / AccessControl `onlyRole(GUARDIAN_ROLE)` to adminDrain().
- Or make the function self-service: derive the destination from `msg.sender` and let users pull their own funds.
- Emit an event and add a per-call cap so a wrong grant of authority is bounded.
PoC stub: pocs/sg-evm-006_sg_evm_006_missing_access_contro.t.sol
run: forge test --match-contract SgEvm006MissingAccessControlOnAdmindrainHarness -vvvv
skill skills/access-control/SKILL.md checklist satisfied:
- Each privileged function has either a modifier (`onlyOwner`, `onlyRole`, `onlyAdmin`)
- Build the list of externally callable functions and mark which ones mutate privileged state.
critical Reentrancy: state updated after external call in adminDrain() solidity/Vault.sol:85 (adminDrain) conf 0.90 · CEI-001 · evm
adminDrain() performs an external interaction (to.call) at line 85 and only updates contract state afterwards (totalDeposits). A callee can re-enter adminDrain() during that callback and still see the pre-call balance, so the same funds can be withdrawn repeatedly. No reentrancy guard and no checks-effects-interactions ordering were found in this function.
corroborated independently by 2 other detector(s), 2 rule(s): AC-001 (SG-EVM-006), SD-002 (SG-EVM-009) (annotation only — does not change this grade)
(bool sent, ) = to.call{value: payout}("");
- Deploy a malicious callee whose fallback/receive() calls back into adminDrain().
- On the first call, pass an amount the contract still thinks you are owed.
- Inside the callback, call adminDrain() again before the balance subtraction at line 87 has executed - the old balance is still readable.
- Repeat until the contract's native/ERC20 balance is drained.
- Update all accounting before the external call (checks-effects-interactions).
- Or add OpenZeppelin ReentrancyGuard: `nonReentrant` on adminDrain().
- Prefer push payments with an explicit withdraw-then-send ordering.
PoC stub: pocs/sg-evm-007_sg_evm_007_reentrancy_state_upda.t.sol
run: forge test --match-contract SgEvm007ReentrancyStateUpdatedAfterExterHarness -vvvv
skill skills/reentrancy/SKILL.md checklist satisfied:
- State writes (`balances[...]`, totals, flags, allowances, nonces) are ordered strictly
- A reentrancy guard (`nonReentrant`, OZ `ReentrancyGuard`, ERC-7201 transient storage
- before** the interaction - checks-effects-interactions holds.
- Callbacks from the callee (`onERC721Received`, `tokensReceived`, `ERC4626` deposit hooks,
critical Missing signer check on `raw_config` in load_foreign_config() solana/vault/programs/vault/src/lib.rs:217 (load_foreign_config) conf 0.88 · SIGNER-001 · solana
`raw_config` is declared as AccountInfo<info> in the `LoadConfig` accounts struct, so Anchor cannot prove it was signed. load_foreign_config() still uses it to authorise a privileged state change. On Solana an unsigned account is just bytes anyone can craft: an attacker supplies their own account with the right key/shape and the program accepts the instruction. The handler contains no `is_signer` check either and no `Signer` account exists in the struct at all.
pub raw_config: AccountInfo<'info>,
- Build the instruction with a self-created account in the `raw_config` slot (it does not need the real owner's key pair).
- Send the transaction - the program sees a well-formed account and no signature requirement fails.
- Repeat for every victim's data the handler writes through that account.
- Type it as `Signer<'info>`: `raw_config: Signer<'info>` (Anchor enforces the signature).
- Or add an explicit `require!(ctx.accounts.raw_config.is_signer, VaultError::MissingSigner);`.
- Better: bind it to state with `has_one = raw_config` plus a PDA `seeds` constraint.
PoC stub: pocs/sg-sol-002_sg_sol_002_missing_signer_check_.rs
run: cargo test --features test-suites sg_sol_002_missing_signer_check_on_raw_config_in
skill skills/solana-signer/SKILL.md checklist satisfied:
- Every account whose **signature** is required is typed `Signer<'info>`; the type system is
- The key stored as the future authority (`vault.authority = authority.key()`) comes from a
critical AMM spot price used as valuation in assetValue() solidity/Vault.sol:120 (assetValue) conf 0.88 · OR-001 · evm
assetValue() derives a price from in-pool reserves (`getReserves`), which is the pool's own balance ratio rather than a traded price. Any caller with enough capital - typically a flash loan - can skew that ratio in a single transaction, and the contract will read the manipulated number in the same block. There is no time-weighting and no second source here.
(uint112 reserve0, uint112 reserve1, ) = pool.getReserves();
- Flash-loan a large amount of one side of the pool.
- Swap it into the pool to move reserves (and therefore the printed price) in the same tx.
- Call assetValue() - the inflated price is now the contract's truth - then mint/borrow/liquidate.
- Swap back and repay the loan; the profit is the difference in the contract's accounting.
- Use a time-weighted price (Chainlink TWAP or a multi-block Uniswap V2 `cumulativePrice` window).
- Never let an in-block reserve ratio set collateral or payout values.
- Add a max-deviation guard against a second source and reject out-of-band prints.
PoC stub: pocs/sg-evm-008_sg_evm_008_amm_spot_price_used_a.t.sol
run: forge test --match-contract SgEvm008AmmSpotPriceUsedAsValuationInAssHarness -vvvv
skill skills/oracle-price/SKILL.md checklist satisfied:
- Identify the pricing source: in-pool reserves (spot), a single Chainlink round, a
- The price cannot be moved inside the same transaction that consumes it (flash-loan
- A second, independent source or a max-deviation circuit breaker exists for anything that
critical withdraw() writes state / moves value with no `Signer` account at all solana/vault/programs/vault/src/lib.rs:54 (withdraw) conf 0.85 · SIGNER-002 · solana
The `Withdraw` constraint list contains no `Signer`, yet withdraw() performs a privileged operation (`.lamports_mut(`). Permissionless mutation is exactly the class of bug that empties programs on Solana: there is no `msg.sender` equivalent to fall back on, so the account list is the only authorisation surface.
corroborated independently by 2 other detector(s), 2 rule(s): AUTH-003 (SG-SOL-021), MATH-002 (SG-SOL-023) (annotation only — does not change this grade)
ctx.accounts.user_authority.lamports_mut(|l| *l += amount)?;
- Call withdraw directly from any wallet - no signature from the configured authority is required.
- Point the writable accounts at whatever you want changed.
- Add `authority: Signer<'info>` and `#[account(constraint = authority.key() == vault_config.authority)]`.
- Or derive the writable account as a PDA and sign with `seeds` so only the program can author it.
PoC stub: pocs/sg-sol-003_sg_sol_003_withdraw_writes_state.rs
run: cargo test --features test-suites sg_sol_003_withdraw_writes_state_moves_value_wit
skill skills/solana-signer/SKILL.md checklist satisfied:
- Every account whose **signature** is required is typed `Signer<'info>`; the type system is
critical slash() writes state / moves value with no `Signer` account at all solana/vault/programs/vault/src/lib.rs:68 (slash) conf 0.85 · SIGNER-002 · solana
The `Slash` constraint list contains no `Signer`, yet slash() performs a privileged operation (`+=`). Permissionless mutation is exactly the class of bug that empties programs on Solana: there is no `msg.sender` equivalent to fall back on, so the account list is the only authorisation surface.
corroborated independently by 2 other detector(s), 2 rule(s): CPI-003 (SG-SOL-015), MATH-004 (SG-SOL-025) (annotation only — does not change this grade)
ctx.accounts.vault_token_account.amount += shares;
- Call slash directly from any wallet - no signature from the configured authority is required.
- Point the writable accounts at whatever you want changed.
- Add `authority: Signer<'info>` and `#[account(constraint = authority.key() == vault_config.authority)]`.
- Or derive the writable account as a PDA and sign with `seeds` so only the program can author it.
PoC stub: pocs/sg-sol-004_sg_sol_004_slash_writes_state_mo.rs
run: cargo test --features test-suites sg_sol_004_slash_writes_state_moves_value_with_n
skill skills/solana-signer/SKILL.md checklist satisfied:
- Every account whose **signature** is required is typed `Signer<'info>`; the type system is
critical set_limiter() writes state / moves value with no `Signer` account at all solana/vault/programs/vault/src/lib.rs:76 (set_limiter) conf 0.85 · SIGNER-002 · solana
The `SetLimiter` constraint list contains no `Signer`, yet set_limiter() performs a privileged operation (`ctx.accounts.vault_config.limiter = `). Permissionless mutation is exactly the class of bug that empties programs on Solana: there is no `msg.sender` equivalent to fall back on, so the account list is the only authorisation surface.
ctx.accounts.vault_config.limiter = limiter;
- Call set_limiter directly from any wallet - no signature from the configured authority is required.
- Point the writable accounts at whatever you want changed.
- Add `authority: Signer<'info>` and `#[account(constraint = authority.key() == vault_config.authority)]`.
- Or derive the writable account as a PDA and sign with `seeds` so only the program can author it.
PoC stub: pocs/sg-sol-005_sg_sol_005_set_limiter_writes_st.rs
run: cargo test --features test-suites sg_sol_005_set_limiter_writes_state_moves_value_
skill skills/solana-signer/SKILL.md checklist satisfied:
- Every account whose **signature** is required is typed `Signer<'info>`; the type system is
critical Token CPI over unvalidated account `user_token_account` in deposit() solana/vault/programs/vault/src/lib.rs:39 (deposit) conf 0.80 · ACCT-002 · solana
deposit() hands `ctx.accounts.user_token_account` to an SPL token CPI while the account is only an UncheckedAccount. Nothing proves its owner is the token program, that it is the vault's token account, or that its mint is the expected one. The CPI happily operates on whatever account the client supplied, which is the standard way vaults end up paying out of the wrong pot.
corroborated independently by 1 other detector(s), 3 rule(s): MATH-001 (SG-SOL-010), MATH-002 (SG-SOL-022), MATH-003 (SG-SOL-016) (annotation only — does not change this grade)
from: ctx.accounts.user_token_account.to_account_info(),
- Point `user_token_account` at a token account you control (or at any account whose data you can shape).
- The vault debits/credits the wrong account; your own balance is the source of truth for it.
- `#[account(constraint = user_token_account.owner == program_authority.key() && user_token_account.mint == expected_mint)]`
- Type it `Account<'info, TokenAccount>` so Anchor deserialises and checks the owner.
PoC stub: pocs/sg-sol-006_sg_sol_006_token_cpi_over_unvali.rs
run: cargo test --features test-suites sg_sol_006_token_cpi_over_unvalidated_account_us
skill skills/solana-account-validation/SKILL.md checklist satisfied:
- SPL accounts assert `owner == token_program`, the right `mint`, and the expected authority
critical CPI target `signer_program` is an unvalidated account, not a Program solana/vault/programs/vault/src/lib.rs:208 (settle_external) conf 0.80 · CPI-002 · solana
`signer_program` is declared as UncheckedAccount in `SettleExternal` and is used as the program id for a cross-program call. Anchor only checks that an account is a *program* when it is typed `Program<'info, T>` (or when `is_program()` is called). Here the client picks the 'program', so the instruction is executed against code the program never vouched for - typically a spoofed token program that returns success without moving anything.
corroborated independently by 1 other detector(s), 1 rule(s): ACCT-001 (SG-SOL-013) (annotation only — does not change this grade)
pub signer_program: UncheckedAccount<'info>,
- Deploy a fake program whose address you pass as `signer_program`.
- It deserialises the args, returns Ok, and moves nothing.
- The vault credits your position anyway - mint-without-deposit.
- Type the account `Program<'info, anchor_spl::token::Token>` (or `System`).
- Or `require!(ctx.accounts.signer_program.is_program(), VaultError::InvalidProgram);`.
- Even better, use the CPI helper (`token::transfer`) which resolves the program from `CpiContext` types instead of a client field.
PoC stub: pocs/sg-sol-007_sg_sol_007_cpi_target_signer_pro.rs
run: cargo test --features test-suites sg_sol_007_cpi_target_signer_program_is_an_unval
skill skills/solana-cpi/SKILL.md checklist satisfied:
- The callee is a `Program<'info, T>` account (or `is_program()` verified), never a raw
critical Value sent to a caller-chosen address in adminDrain() solidity/Vault.sol:85 (adminDrain) conf 0.80 · SD-002 · evm
adminDrain() moves native value to `to`, which is supplied by the caller rather than derived from the contract's own accounting. It is unguarded. Any caller (or any caller that can reach this path) chooses the destination, so shared funds can be redirected at will.
corroborated independently by 2 other detector(s), 2 rule(s): AC-001 (SG-EVM-006), CEI-001 (SG-EVM-007) (annotation only — does not change this grade)
(bool sent, ) = to.call{value: payout}("");
- Call adminDrain() with `to` set to an address you control.
- Repeat for each victim's recorded balance if the function is parameterised by user.
- Derive the recipient from the caller (`msg.sender`) or from a per-user withdrawal map.
- Use pull-payments: credit an internal balance, let users claim it themselves.
PoC stub: pocs/sg-evm-009_sg_evm_009_value_sent_to_a_calle.t.sol
run: forge test --match-contract SgEvm009ValueSentToACallerChosenAddressIHarness -vvvv
skill skills/fund-recovery/SKILL.md checklist satisfied:
- No value-moving call takes its destination from instruction arguments without deriving it from
high Missing signer check on `reporter` in slash() solana/vault/programs/vault/src/lib.rs:185 (slash) conf 0.88 · SIGNER-001 · solana
`reporter` is declared as UncheckedAccount<info> in the `Slash` accounts struct, so Anchor cannot prove it was signed. slash() still uses it to authorise a privileged decision. On Solana an unsigned account is just bytes anyone can craft: an attacker supplies their own account with the right key/shape and the program accepts the instruction. The handler contains no `is_signer` check either and no `Signer` account exists in the struct at all.
pub reporter: UncheckedAccount<'info>,
- Build the instruction with a self-created account in the `reporter` slot (it does not need the real owner's key pair).
- Send the transaction - the program sees a well-formed account and no signature requirement fails.
- Repeat for every victim's data the handler writes through that account.
- Type it as `Signer<'info>`: `reporter: Signer<'info>` (Anchor enforces the signature).
- Or add an explicit `require!(ctx.accounts.reporter.is_signer, VaultError::MissingSigner);`.
- Better: bind it to state with `has_one = reporter` plus a PDA `seeds` constraint.
PoC stub: pocs/sg-sol-008_sg_sol_008_missing_signer_check_.rs
run: cargo test --features test-suites sg_sol_008_missing_signer_check_on_reporter_in_s
skill skills/solana-signer/SKILL.md checklist satisfied:
- Every account whose **signature** is required is typed `Signer<'info>`; the type system is
- The key stored as the future authority (`vault.authority = authority.key()`) comes from a
high Missing signer check on `authority` in set_limiter() solana/vault/programs/vault/src/lib.rs:199 (set_limiter) conf 0.88 · SIGNER-001 · solana
`authority` is declared as AccountInfo<info> in the `SetLimiter` accounts struct, so Anchor cannot prove it was signed. set_limiter() still uses it to authorise a privileged decision. On Solana an unsigned account is just bytes anyone can craft: an attacker supplies their own account with the right key/shape and the program accepts the instruction. The handler contains no `is_signer` check either and no `Signer` account exists in the struct at all.
pub authority: AccountInfo<'info>,
- Build the instruction with a self-created account in the `authority` slot (it does not need the real owner's key pair).
- Send the transaction - the program sees a well-formed account and no signature requirement fails.
- Repeat for every victim's data the handler writes through that account.
- Type it as `Signer<'info>`: `authority: Signer<'info>` (Anchor enforces the signature).
- Or add an explicit `require!(ctx.accounts.authority.is_signer, VaultError::MissingSigner);`.
- Better: bind it to state with `has_one = authority` plus a PDA `seeds` constraint.
PoC stub: pocs/sg-sol-009_sg_sol_009_missing_signer_check_.rs
run: cargo test --features test-suites sg_sol_009_missing_signer_check_on_authority_in_
skill skills/solana-signer/SKILL.md checklist satisfied:
- Every account whose **signature** is required is typed `Signer<'info>`; the type system is
- The key stored as the future authority (`vault.authority = authority.key()`) comes from a
high Precision loss: integer division before multiplication in deposit() solana/vault/programs/vault/src/lib.rs:31 (deposit) conf 0.85 · MATH-001 · solana
deposit() computes `(amount / vault.total_deposits) * 1_000_000_000`. Rust integers truncate, so dividing first throws away the remainder before the scaling factor is applied: for `amount < divisor` the result is exactly zero, and for larger amounts the user loses up to `divisor-1` units of precision per operation. This is the classic share-accounting bug - deposits silently credit 0 shares.
corroborated independently by 1 other detector(s), 2 rule(s): ACCT-001 (SG-SOL-012), ACCT-002 (SG-SOL-006) (annotation only — does not change this grade)
let shares = (amount / vault.total_deposits) * 1_000_000_000; // precision loss
- Deposit an amount smaller than the divisor (`total_deposits` here) and receive 0 shares.
- Do it repeatedly: the pool's recorded deposits grow while your share balance stays at zero - or, inverted, you can take shares that were never paid for.
- Round the other way at withdrawal to extract the residual dust on every user.
- Multiply first, divide last: `let shares = amount.checked_mul(SCALE)?.checked_div(total)?;`
- Use `u128` intermediates (`(amount as u128 * SCALE / total) as u64`).
- Track a virtual offset (ERC-4626 style inflation guard) so dust cannot be free-minted.
PoC stub: pocs/sg-sol-010_sg_sol_010_precision_loss_intege.rs
run: cargo test --features test-suites sg_sol_010_precision_loss_integer_division_befor
skill skills/solana-math/SKILL.md checklist satisfied:
- Multiplication happens **before** division; division-first expressions are precision-loss
- Rounding direction is deliberate and always favours the pool (virtual offset / ERC-4626-style
high Unchecked CPI: result of `program::invoke` discarded in settle_external() solana/vault/programs/vault/src/lib.rs:94 (settle_external) conf 0.85 · CPI-001 · solana
settle_external() performs a cross-program invocation and throws the `Result` away (no `?`, no `require!`). If the callee reverts, the CPI error is swallowed and this handler still returns `Ok(())`, so the program records the external operation as if it succeeded. Every downstream accounting decision then runs on a state that never happened.
corroborated independently by 1 other detector(s), 1 rule(s): ACCT-001 (SG-SOL-013) (annotation only — does not change this grade)
anchor_lang::solana_program::program::invoke(&instruction, &account_infos);
- Force the callee to fail (insufficient funds, wrong owner, frozen account).
- The CPI error never propagates; settle_external() returns success.
- The program's own bookkeeping now diverges from on-chain reality - withdraw against it.
- Propagate: `program::invoke(&instruction, &account_infos)?;`
- Or `require!(result.is_ok(), VaultError::CpiFailed);` with a real error variant.
- Return the CPI error unchanged so clients see why the transaction failed.
PoC stub: pocs/sg-sol-011_sg_sol_011_unchecked_cpi_result_.rs
run: cargo test --features test-suites sg_sol_011_unchecked_cpi_result_of_program_invok
skill skills/solana-cpi/SKILL.md checklist satisfied:
- Every CPI result is propagated (`?`) or explicitly handled; a dropped `Result` means the
high Unchecked value transfer result in claimTreasuryGrant() solidity/HyperVault.sol:89 (claimTreasuryGrant) conf 0.85 · EC-001 · hyperevm
claimTreasuryGrant() calls out to e(msg.sender) and ignores the result. e(msg.sender).transfer() can fail silently: low-level `call` returns false instead of reverting, and `send`/`transfer` return false when the recipient cannot accept the value. State that was already updated before the call is not rolled back, so the contract's view of itself diverges from the chain - for accounting code that is a direct loss of user funds.
corroborated independently by 2 other detector(s), 5 rule(s): CEI-001 (SG-EVM-003), SR-001 (SG-EVM-017), SR-002 (SG-EVM-002), SR-003 (SG-EVM-020), SR-004 (SG-EVM-023) (annotation only — does not change this grade)
require(signer == admin, "hyprivault: bad signature");
- Make the callee fail on purpose: revert, run out of gas, or return false (non-standard ERC20).
- Trigger claimTreasuryGrant() so the accounting step succeeds while the value move fails.
- Withdraw/claim again with the state still advanced.
- `(bool ok, ) = target.call{value: v}(""); require(ok, "transfer failed");`
- Or use OpenZeppelin Address.sendValue with an explicit revert.
- For non-standard ERC20s use `SafeERC20.safeTransfer` instead of `transfer`.
PoC stub: pocs/sg-evm-010_sg_evm_010_unchecked_value_trans.t.sol
run: forge test --match-contract SgEvm010UncheckedValueTransferResultInClHarness -vvvv
skill skills/external-calls/SKILL.md checklist satisfied:
- Non-standard ERC20s (USDT-style no-return, fee-on-transfer, rebasing) are handled with
high tx.origin used for authorisation in setOperator() solidity/Vault.sol:94 (setOperator) conf 0.85 · AC-002 · evm
setOperator() authorises the caller with `tx.origin` instead of `msg.sender`. tx.origin is the EOA that started the transaction and it keeps equaling the owner even when the owner is tricked into calling a malicious contract first. Any phishing-style interaction therefore hands over full authorisation of this function, which is exactly why Solidity deprecated the pattern.
require(tx.origin == owner, "vault: not owner (tx.origin)");
- Lure the authorised EOA into calling a contract you control (a small incentive is enough).
- From that contract's fallback, call setOperator(): `tx.origin == owner` still holds.
- Authorisation is granted while `msg.sender` is your contract.
- Compare against `msg.sender`.
- If anti-contract phishing resistance is genuinely wanted, add `require(tx.origin == msg.sender)` *in addition to* the sender check, never instead of it.
PoC stub: pocs/sg-evm-011_sg_evm_011_tx_origin_used_for_au.t.sol
run: forge test --match-contract SgEvm011TxOriginUsedForAuthorisationInSeHarness -vvvv
skill skills/access-control/SKILL.md checklist satisfied:
- `tx.origin` is **never** the authorisation basis (`msg.sender` is the caller).
high Unchecked value transfer result in refundPending() solidity/Vault.sol:111 (refundPending) conf 0.85 · EC-001 · evm
refundPending() calls out to n and ignores the result. n.transfer() can fail silently: low-level `call` returns false instead of reverting, and `send`/`transfer` return false when the recipient cannot accept the value. State that was already updated before the call is not rolled back, so the contract's view of itself diverges from the chain - for accounting code that is a direct loss of user funds.
corroborated independently by 1 other detector(s), 1 rule(s): AC-001 (SG-EVM-019) (annotation only — does not change this grade)
pendingRewards[user] = 0;
- Make the callee fail on purpose: revert, run out of gas, or return false (non-standard ERC20).
- Trigger refundPending() so the accounting step succeeds while the value move fails.
- Withdraw/claim again with the state still advanced.
- `(bool ok, ) = target.call{value: v}(""); require(ok, "transfer failed");`
- Or use OpenZeppelin Address.sendValue with an explicit revert.
- For non-standard ERC20s use `SafeERC20.safeTransfer` instead of `transfer`.
PoC stub: pocs/sg-evm-012_sg_evm_012_unchecked_value_trans.t.sol
run: forge test --match-contract SgEvm012UncheckedValueTransferResultInReHarness -vvvv
skill skills/external-calls/SKILL.md checklist satisfied:
- Non-standard ERC20s (USDT-style no-return, fee-on-transfer, rebasing) are handled with
high Missing access control on setReserveFactor() (writable: reserveFactorBps) solidity/HyperVault.sol:94 (setReserveFactor) conf 0.82 · AC-003 · hyperevm
setReserveFactor() rewrites security-critical storage (reserveFactorBps) with no authorisation check. An unguarded price feed, fee, cap or reserve parameter is the usual first step of a composed exploit: the attacker does not break the vault directly, they turn the vault's own inputs until a legitimate code path pays them. On HyperEVM treat HyperCore-style reads with the same caution.
function setReserveFactor(uint256 bps) external {
- Call setReserveFactor() to install a price/parameter you control.
- Then trigger the normal accounting path (mint, redeem, liquidate) that reads it.
- Guard with `onlyOwner`/role, plus an `Upgraded`/`ParamSet` event.
- Add a max-move bound and a timelock for security-critical values.
- Prefer AccessControl roles over a single `owner` address.
PoC stub: pocs/sg-evm-013_sg_evm_013_missing_access_contro.t.sol
run: forge test --match-contract SgEvm013MissingAccessControlOnSetreserveHarness -vvvv
skill skills/access-control/SKILL.md checklist satisfied:
- Each privileged function has either a modifier (`onlyOwner`, `onlyRole`, `onlyAdmin`)
- Build the list of externally callable functions and mark which ones mutate privileged state.
high Oracle round read without staleness/round checks in assetValue() solidity/Vault.sol:121 (assetValue) conf 0.82 · OR-002 · evm
assetValue() takes the answer from `latestRoundData()` but never inspects `updatedAt`, `startedAt`, `answeredInRound` or `roundId`. A stale round, a round still in progress, or a negative/zero answer therefore passes straight through, and the contract keeps pricing off a print that may be hours old. The `require(answer > 0)` guard alone is not a freshness check.
(, int256 answer, , , ) = priceFeed.latestRoundData();
- Wait for the feed to stop updating (heartbeat exceeded) during volatility, or during a keeper outage.
- Trade the underlying asset so the market moves while the contract still uses the old print.
- Borrow/liquidate against the stale valuation.
- Require `updatedAt != 0 && block.timestamp - updatedAt <= MAX_STALENESS_SECONDS`.
- Require `answeredInRound >= roundId` and `answer > 0`.
- Fall back to a second feed (or pause) when the check fails.
PoC stub: pocs/sg-evm-014_sg_evm_014_oracle_round_read_wit.t.sol
run: forge test --match-contract SgEvm014OracleRoundReadWithoutStalenessRHarness -vvvv
skill skills/oracle-price/SKILL.md checklist satisfied:
- Chainlink reads validate all four of: `updatedAt` freshness, `answeredInRound >= roundId`,
high Missing access control on setPriceFeed() (writable: priceFeed) solidity/Vault.sol:130 (setPriceFeed) conf 0.82 · AC-003 · evm
setPriceFeed() rewrites security-critical storage (priceFeed) with no authorisation check. An unguarded price feed, fee, cap or reserve parameter is the usual first step of a composed exploit: the attacker does not break the vault directly, they turn the vault's own inputs until a legitimate code path pays them. On this chain treat HyperCore-style reads with the same caution.
function setPriceFeed(address feed) external {
- Call setPriceFeed() to install a price/parameter you control.
- Then trigger the normal accounting path (mint, redeem, liquidate) that reads it.
- Guard with `onlyOwner`/role, plus an `Upgraded`/`ParamSet` event.
- Add a max-move bound and a timelock for security-critical values.
- Prefer AccessControl roles over a single `owner` address.
PoC stub: pocs/sg-evm-015_sg_evm_015_missing_access_contro.t.sol
run: forge test --match-contract SgEvm015MissingAccessControlOnSetpricefeHarness -vvvv
skill skills/access-control/SKILL.md checklist satisfied:
- Each privileged function has either a modifier (`onlyOwner`, `onlyRole`, `onlyAdmin`)
- Build the list of externally callable functions and mark which ones mutate privileged state.
high Unvalidated account type: `user_token_account` is UncheckedAccount in Deposit solana/vault/programs/vault/src/lib.rs:154 (deposit) conf 0.80 · ACCT-001 · solana
`user_token_account` in the `Deposit` constraint list is typed as UncheckedAccount<info>, so Anchor performs no deserialisation, owner or discriminator check on it - yet deposit() passes it into program logic. That is account-confusion territory: an attacker can substitute a look-alike account (wrong owner, wrong mint, wrong program) and the handler has no way to notice. Typing it as `Account<'info, T>` / `Program<'info, T>` makes the runtime enforce it for free.
corroborated independently by 1 other detector(s), 3 rule(s): MATH-001 (SG-SOL-010), MATH-002 (SG-SOL-022), MATH-003 (SG-SOL-016) (annotation only — does not change this grade)
pub user_token_account: UncheckedAccount<'info>,
- Create any account with a matching shape (or reuse one you own) and place it in the `user_token_account` slot.
- Because ownership/discriminator are not verified, the handler treats it as trusted.
- Combine with the missing signer path to redirect value.
- Use a concrete type: `Account<'info, TokenAccount>` / `Program<'info, Token>` / `Account<'info, VaultPda>`.
- Or validate by hand: `require!(acc.owner == &anchor_spl::token::ID, ...)` and check the discriminator/length.
- Add `#[account(..., token::mint = expected_mint)]` style constraints for SPL accounts.
PoC stub: pocs/sg-sol-012_sg_sol_012_unvalidated_account_t.rs
run: cargo test --features test-suites sg_sol_012_unvalidated_account_type_user_token_a
skill skills/solana-account-validation/SKILL.md checklist satisfied:
- SPL accounts assert `owner == token_program`, the right `mint`, and the expected authority
- Every account that authorises a privileged action is typed `Signer<'info>` (or its
high Unvalidated account type: `signer_program` is UncheckedAccount in SettleExternal solana/vault/programs/vault/src/lib.rs:208 (settle_external) conf 0.80 · ACCT-001 · solana
`signer_program` in the `SettleExternal` constraint list is typed as UncheckedAccount<info>, so Anchor performs no deserialisation, owner or discriminator check on it - yet settle_external() passes it into program logic. That is account-confusion territory: an attacker can substitute a look-alike account (wrong owner, wrong mint, wrong program) and the handler has no way to notice. Typing it as `Account<'info, T>` / `Program<'info, T>` makes the runtime enforce it for free.
corroborated independently by 1 other detector(s), 2 rule(s): CPI-001 (SG-SOL-011), CPI-002 (SG-SOL-007) (annotation only — does not change this grade)
pub signer_program: UncheckedAccount<'info>,
- Create any account with a matching shape (or reuse one you own) and place it in the `signer_program` slot.
- Because ownership/discriminator are not verified, the handler treats it as trusted.
- Combine with the missing signer path to redirect value.
- Use a concrete type: `Account<'info, TokenAccount>` / `Program<'info, Token>` / `Account<'info, VaultPda>`.
- Or validate by hand: `require!(acc.owner == &anchor_spl::token::ID, ...)` and check the discriminator/length.
- Add `#[account(..., token::mint = expected_mint)]` style constraints for SPL accounts.
PoC stub: pocs/sg-sol-013_sg_sol_013_unvalidated_account_t.rs
run: cargo test --features test-suites sg_sol_013_unvalidated_account_type_signer_progr
skill skills/solana-account-validation/SKILL.md checklist satisfied:
- SPL accounts assert `owner == token_program`, the right `mint`, and the expected authority
- Every account that authorises a privileged action is typed `Signer<'info>` (or its
high Unvalidated account type: `raw_config` is AccountInfo in LoadConfig solana/vault/programs/vault/src/lib.rs:217 (load_foreign_config) conf 0.80 · ACCT-001 · solana
`raw_config` in the `LoadConfig` constraint list is typed as AccountInfo<info>, so Anchor performs no deserialisation, owner or discriminator check on it - yet load_foreign_config() passes it into program logic. That is account-confusion territory: an attacker can substitute a look-alike account (wrong owner, wrong mint, wrong program) and the handler has no way to notice. Typing it as `Account<'info, T>` / `Program<'info, T>` makes the runtime enforce it for free.
corroborated independently by 1 other detector(s), 1 rule(s): DESER-001 (SG-SOL-001) (annotation only — does not change this grade)
pub raw_config: AccountInfo<'info>,
- Create any account with a matching shape (or reuse one you own) and place it in the `raw_config` slot.
- Because ownership/discriminator are not verified, the handler treats it as trusted.
- Combine with the missing signer path to redirect value.
- Use a concrete type: `Account<'info, TokenAccount>` / `Program<'info, Token>` / `Account<'info, VaultPda>`.
- Or validate by hand: `require!(acc.owner == &anchor_spl::token::ID, ...)` and check the discriminator/length.
- Add `#[account(..., token::mint = expected_mint)]` style constraints for SPL accounts.
PoC stub: pocs/sg-sol-014_sg_sol_014_unvalidated_account_t.rs
run: cargo test --features test-suites sg_sol_014_unvalidated_account_type_raw_config_i
skill skills/solana-account-validation/SKILL.md checklist satisfied:
- SPL accounts assert `owner == token_program`, the right `mint`, and the expected authority
- Every account that authorises a privileged action is typed `Signer<'info>` (or its
high Unguarded write to proxy logic pointer `implementation` solidity/HyperVault.sol:61 (setImplementation) conf 0.80 · DC-002 · hyperevm
setImplementation() assigns `implementation` with no authorisation check. Whoever controls that slot controls the logic every delegatecall in this proxy resolves to, which is equivalent to owning the contract and all of its storage. This is the storage-collision/upgrade footgun class: the slot is also shared with the implementation's own layout, so ordering matters.
implementation = newImpl;
- Point `implementation` at a contract you wrote.
- Trigger the fallback/delegate path - all subsequent calls run your code in this storage.
- Guard with `onlyProxyAdmin` + AccessControl role, and emit an Upgraded event.
- Use EIP-1967/ERC-1967 upgradeable proxies from OpenZeppelin rather than a hand-rolled slot.
- Keep implementation state in a namespaced storage gap to avoid collisions.
PoC stub: pocs/sg-evm-016_sg_evm_016_unguarded_write_to_pr.t.sol
run: forge test --match-contract SgEvm016UnguardedWriteToProxyLogicPointeHarness -vvvv
skill skills/delegatecall-proxy/SKILL.md checklist satisfied:
- Writes to logic pointers are guarded by an admin role **and** a timelock/delay, and emit an
high Unbound signature digest in claimTreasuryGrant() (cross-chain / cross-contract replay) solidity/HyperVault.sol:88 (claimTreasuryGrant) conf 0.80 · SR-001 · hyperevm
claimTreasuryGrant() verifies a signature with raw `ecrecover` on a caller-supplied hash. The digest does not commit to the verifying contract address or the chain id (no EIP-712 domain separator), so one valid signature is valid everywhere the same bytecode is deployed - every fork, every testnet-to-mainnet copy, and every sibling contract that shares the signer.
corroborated independently by 2 other detector(s), 3 rule(s): CEI-001 (SG-EVM-003), EC-001 (SG-EVM-010), EC-003 (SG-EVM-024) (annotation only — does not change this grade)
address signer = ecrecover(doc, v, r, s);
- Capture a legitimate signed payload on chain A (or from any off-chain consumer of the signer).
- Replay it verbatim against the same deployment on chain B / a sibling contract.
- No forging is required: the signature is authentic, just in the wrong context.
- Use OpenZeppelin EIP712 + `ECDSA.recover(typedDigest, v, r, s)` so the domain binds `address(this)` and `block.chainid`.
- If hand-rolled, hash `0x19 0x01 domainSeparator structHash` and cache the domain separator including chainid so forks invalidate it.
PoC stub: pocs/sg-evm-017_sg_evm_017_unbound_signature_dig.t.sol
run: forge test --match-contract SgEvm017UnboundSignatureDigestInClaimtreHarness -vvvv
skill skills/signature-replay/SKILL.md checklist satisfied:
- The digest commits to the verifying context: EIP-712 domain separator with `name`,
high Stranded funds: recoverTokens() is gated on `pendingAdmin`, which is never assigned solidity/HyperVault.sol:109 (recoverTokens) conf 0.80 · SF-001 · hyperevm
`pendingAdmin` is declared as a storage address and used as the only authorisation for recoverTokens(), but nothing in this contract ever assigns it. It is therefore zero (`address(0)`), and no caller can ever satisfy the check. The function is dead code, which means value that only this path can move - ETH and tokens held by the contract - is permanently stranded.
require(msg.sender == pendingAdmin, "hyprivault: not pending admin");
- No attacker required: send value to the contract (or let it accumulate) and the recovery path can never be executed.
- If the gate is `msg.sender == address(0)`, the check can even be satisfied by a crafted creation flow, turning dead code into an open door.
- Assign the variable in the constructor or an initialiser, or delete the path.
- Add a test asserting the recovery function is callable by the intended role.
- Prefer AccessControl roles with `grantRole` at deploy time over bare address variables.
PoC stub: pocs/sg-evm-018_sg_evm_018_stranded_funds_recove.t.sol
run: forge test --match-contract SgEvm018StrandedFundsRecovertokensIsGateHarness -vvvv
skill skills/fund-recovery/SKILL.md checklist satisfied:
- Token recovery uses `SafeERC20` and checks the return data - a rescue that silently fails is
high Missing access control on refundPending() solidity/Vault.sol:106 (refundPending) conf 0.75 · AC-001 · evm
refundPending() is public, moves value to `user`, and writes owed, yet nothing in the function proves who the caller is: no `only*` modifier, no `require(msg.sender == ...)`, no role check, and no caller-scoped accounting. So the state it touches is not limited to the caller's own balance - any address can call it. The destination is a named user, so the direct risk is griefing/replay of that user's claim rather than an empty contract.
corroborated independently by 1 other detector(s), 1 rule(s): EC-001 (SG-EVM-012) (annotation only — does not change this grade)
function refundPending(address user) public returns (uint256 paid) {
- Call refundPending() from an EOA you control, with the arguments you want.
- No key compromise or front-running is required - the entry point is permissionless.
- Repeat until the shared balance is gone (or until every victim's claim has been consumed).
- Add `onlyOwner` / AccessControl `onlyRole(GUARDIAN_ROLE)` to refundPending().
- Or make the function self-service: derive the destination from `msg.sender` and let users pull their own funds.
- Emit an event and add a per-call cap so a wrong grant of authority is bounded.
PoC stub: pocs/sg-evm-019_sg_evm_019_missing_access_contro.t.sol
run: forge test --match-contract SgEvm019MissingAccessControlOnRefundpendHarness -vvvv
skill skills/access-control/SKILL.md checklist satisfied:
- Each privileged function has either a modifier (`onlyOwner`, `onlyRole`, `onlyAdmin`)
- Build the list of externally callable functions and mark which ones mutate privileged state.
high SPL token balance edited directly on `vault_token_account` instead of via CPI solana/vault/programs/vault/src/lib.rs:68 (slash) conf 0.70 · CPI-003 · solana
slash() writes `ctx.accounts.vault_token_account.amount` directly. An SPL token account is owned by the token program, so a well-behaved runtime will reject the mutation; where the owner check is weak (or the account was passed as a raw/duplicated account) the balance can be forged inside the transaction, letting an attacker mint purchasing power without moving tokens.
corroborated independently by 2 other detector(s), 2 rule(s): MATH-004 (SG-SOL-025), SIGNER-002 (SG-SOL-004) (annotation only — does not change this grade)
ctx.accounts.vault_token_account.amount += shares;
- Pass an account the handler treats as the vault token account.
- Direct mutation path (or a CPI on an unvalidated program) inflates `amount`.
- Withdraw/settle logic trusts `amount` and releases real tokens.
- Move balances with `token::transfer` / `token::mint_to` CPIs signed by the vault PDA.
- Keep program-owned state in your own accounts, never in foreign-owned layouts.
PoC stub: pocs/sg-sol-015_sg_sol_015_spl_token_balance_edi.rs
run: cargo test --features test-suites sg_sol_015_spl_token_balance_edited_directly_on_
skill skills/solana-cpi/SKILL.md checklist satisfied:
- Every CPI result is propagated (`?`) or explicitly handled; a dropped `Result` means the
medium Division by `vault.total_deposits` with no zero guard in deposit() solana/vault/programs/vault/src/lib.rs:31 (deposit) conf 0.80 · MATH-003 · solana
deposit() divides by `vault.total_deposits`, which is program state that can legitimately be zero (a fresh vault). On Solana an integer division by zero panics the builtin, the transaction fails with an opaque error, and the first depositor can never complete the flow - an availability bug rather than a theft, but it blocks the account's own happy path.
corroborated independently by 1 other detector(s), 2 rule(s): ACCT-001 (SG-SOL-012), ACCT-002 (SG-SOL-006) (annotation only — does not change this grade)
let shares = (amount / vault.total_deposits) * 1_000_000_000; // precision loss
- Call the handler while the divisor is zero (empty vault) to force the panic.
- If the zero branch is also how shares are seeded, the whole feature is unusable.
- Special-case the empty pool: `let shares = if total == 0 { amount } else { ... };`
- `checked_div(...).ok_or(VaultError::DivideByZero)?` so the failure is a typed error.
PoC stub: pocs/sg-sol-016_sg_sol_016_division_by_vault_tot.rs
run: cargo test --features test-suites sg_sol_016_division_by_vault_total_deposits_with
skill skills/solana-math/SKILL.md checklist satisfied:
- Divisors can never be zero: the empty-vault case is special-cased or guarded with
medium `remaining_accounts` indexed at [0] without a length check solana/vault/programs/vault/src/lib.rs:105 (settle_many) conf 0.75 · REMOT-001 · solana
settle_many() reads `remaining_accounts[0]` from `ctx.remaining_accounts` before checking how many accounts the client actually sent. A short account list panics on the slice index (an uncaught panic in the handler, not a clean program error), and any validation that *would* have happened afterwards is skipped - note the ordering here: the read precedes the `require!`.
let first = &ctx.remaining_accounts[0];
- Send the instruction with fewer remaining accounts than expected.
- The program panics on the index; clients see an opaque failure (DoS of that path).
- Or rely on the missing validation to slip a wrong-role account into position.
- `ensure!(ctx.remaining_accounts.len() >= 2, VaultError::NotEnoughAccounts);` before indexing.
- Iterate with `.iter().next().ok_or(...)` so absence becomes a typed error.
- Validate each remaining account's owner/mint - they are outside Anchor's checks.
PoC stub: pocs/sg-sol-017_sg_sol_017_remaining_accounts_in.rs
run: cargo test --features test-suites sg_sol_017_remaining_accounts_indexed_at_0_witho
skill skills/solana-cpi/SKILL.md checklist satisfied:
- `remaining_accounts` lengths are validated before they are used as CPI accounts.
medium PDA seeds constraint trusts a client-supplied bump solana/vault/programs/vault/src/lib.rs:149 conf 0.75 · ACCT-003 · solana
The account constraint uses `bump = vault_config.bump`, and that stored bump was written from an instruction argument in `initialize`. Anchor will therefore accept *any* bump the client first persisted, so the seeds constraint stops pinning the address to a single canonical PDA. Bump values are part of the address derivation: letting the client choose them is a validation gap.
#[account(mut, seeds = [b"vault", vault_config.key().as_ref()], bump = vault_config.bump)]
- Call `initialize` with a bump that still satisfies the (weak) derivation and store it.
- Reuse that stored bump later to make a seeds check accept a non-canonical address.
- Derive the bump: `bump` (Anchor verifies) or `let (,_ ,bump) = Pubkey::find_program_address(...)`.
- Store the *derived* bump, never the client's.
PoC stub: pocs/sg-sol-018_sg_sol_018_pda_seeds_constraint_.rs
run: cargo test --features test-suites sg_sol_018_pda_seeds_constraint_trusts_a_client_
skill skills/solana-account-validation/SKILL.md checklist satisfied:
- PDAs are derived, not trusted: `seeds` + `bump` verified by Anchor, or
medium PDA seeds constraint trusts a client-supplied bump solana/vault/programs/vault/src/lib.rs:169 conf 0.75 · ACCT-003 · solana
The account constraint uses `bump = vault_config.bump`, and that stored bump was written from an instruction argument in `initialize`. Anchor will therefore accept *any* bump the client first persisted, so the seeds constraint stops pinning the address to a single canonical PDA. Bump values are part of the address derivation: letting the client choose them is a validation gap.
corroborated independently by 1 other detector(s), 1 rule(s): AUTH-002 (SG-SOL-024) (annotation only — does not change this grade)
#[account(mut, seeds = [b"vault", vault_config.key().as_ref()], bump = vault_config.bump, realloc = 8, realloc::payer = vault_config, realloc::zero = false)]
- Call `initialize` with a bump that still satisfies the (weak) derivation and store it.
- Reuse that stored bump later to make a seeds check accept a non-canonical address.
- Derive the bump: `bump` (Anchor verifies) or `let (,_ ,bump) = Pubkey::find_program_address(...)`.
- Store the *derived* bump, never the client's.
PoC stub: pocs/sg-sol-019_sg_sol_019_pda_seeds_constraint_.rs
run: cargo test --features test-suites sg_sol_019_pda_seeds_constraint_trusts_a_client_
skill skills/solana-account-validation/SKILL.md checklist satisfied:
- PDAs are derived, not trusted: `seeds` + `bump` verified by Anchor, or
medium PDA authority depends on a client-supplied `bump` in initialize() solana/vault/programs/vault/src/lib.rs:17 (initialize) conf 0.70 · AUTH-001 · solana
initialize() takes `bump: u8` straight from the instruction and stores it, and other handlers then use that stored value as their seeds constraint (`bump = config.bump`). A PDA is only canonical when the bump is derived with `find_program_address`; storing the caller's number means the first writer decides how authority is derived. Even where `init` seeds constrain this particular deployment, the pattern is how upgrade/freeze/mint authority bugs hide.
pub fn initialize(ctx: Context<Initialize>, bump: u8, limiter: u64) -> Result<()> {
- If the PDA is not uniquely constrained at init, pick a bump that makes a *different* address satisfy the seeds check.
- Then present an account derived from that alternative bump to handlers trusting the stored value.
- Drop the parameter and let Anchor verify the bump (`seeds = [...], bump`).
- Or derive it: `let (_pda, bump) = Pubkey::find_program_address(&[b"vault", authority.as_ref()], &id());`
- If a bump must be stored, assert it equals the derived value on every read.
PoC stub: pocs/sg-sol-020_sg_sol_020_pda_authority_depends.rs
run: cargo test --features test-suites sg_sol_020_pda_authority_depends_on_a_client_sup
skill skills/solana-account-validation/SKILL.md checklist satisfied:
- PDAs are derived, not trusted: `seeds` + `bump` verified by Anchor, or
- SPL accounts assert `owner == token_program`, the right `mint`, and the expected authority
medium No signature expiry in claimTreasuryGrant() solidity/HyperVault.sol:87 (claimTreasuryGrant) conf 0.70 · SR-003 · hyperevm
claimTreasuryGrant() accepts a signature with no deadline and no expiry check. A signer's intent is usually time-bounded - rates, collateral ratios and allowances change - so a signature that was reasonable on the day it was signed can be catastrophic months later, and a leaked offline payload never expires.
corroborated independently by 2 other detector(s), 3 rule(s): CEI-001 (SG-EVM-003), EC-001 (SG-EVM-010), EC-003 (SG-EVM-024) (annotation only — does not change this grade)
function claimTreasuryGrant(bytes32 doc, uint8 v, bytes32 r, bytes32 s, uint256 amount) external {
- Collect signed payloads when conditions are favourable.
- Hold them until prices/limits move in your favour, then submit.
- Put `uint256 deadline` in the signed payload and `require(block.timestamp <= deadline, "expired")`.
- Keep the default TTL short (minutes to hours) and document it for signers.
PoC stub: pocs/sg-evm-020_sg_evm_020_no_signature_expiry_i.t.sol
run: forge test --match-contract SgEvm020NoSignatureExpiryInClaimtreasuryHarness -vvvv
skill skills/signature-replay/SKILL.md checklist satisfied:
- A `deadline` is part of the signed payload and is enforced against `block.timestamp`.
medium Token recovery result is never checked in recoverTokens() solidity/HyperVault.sol:110 (recoverTokens) conf 0.70 · SF-003 · hyperevm
The recovery path does a low-level `token.call(...)` and then discards the boolean (the code shape here is `(bool ok, ) = ...; ok;`). A non-standard or pausable token that returns false makes the caller believe the rescue succeeded, so a failed recovery looks like a completed one.
(bool ok, ) = token.call(abi.encodeWithSelector(0xa9059cbb, to, amount));
- Choose a token that returns false instead of reverting (USDT-style) or that is paused.
- The recovery call reports success while nothing moved.
- `require(ok && (abi.decode(ret, (bool)) ), "transfer failed")` or use SafeERC20.safeTransfer.
- Revert on any zero-length return data as well.
PoC stub: pocs/sg-evm-021_sg_evm_021_token_recovery_result.t.sol
run: forge test --match-contract SgEvm021TokenRecoveryResultIsNeverCheckeHarness -vvvv
skill skills/fund-recovery/SKILL.md checklist satisfied:
- Token recovery uses `SafeERC20` and checks the return data - a rescue that silently fails is
medium Authority account `user_authority` is used by withdraw() with no signer and no binding solana/vault/programs/vault/src/lib.rs:174 (withdraw) conf 0.65 · AUTH-003 · solana
`user_authority` in `Withdraw` reads like an authority (AccountInfo<info>) but is not a `Signer`, is not tied by `has_one`/`constraint`/`seeds`, and withdraw() still consumes it. Any mint / freeze / close authority check the program assumes happens here is really happening on an account the client chose. The privilege exists; nothing in the source limits who exercises it.
corroborated independently by 2 other detector(s), 2 rule(s): MATH-002 (SG-SOL-023), SIGNER-002 (SG-SOL-003) (annotation only — does not change this grade)
pub user_authority: AccountInfo<'info>,
- Supply a different key in the `user_authority` slot (or reuse an account you own that matches the shape).
- Pair it with the missing-signer path in the same handler to complete the authority chain.
- `Signer<'info>` plus `#[account(constraint = user_authority.key() == expected_authority)]`.
- Read SPL authorities from the mint/account data instead of from an unvalidated account.
- Use a two-step authority handover so a single bad call cannot transfer ownership.
PoC stub: pocs/sg-sol-021_sg_sol_021_authority_account_use.rs
run: cargo test --features test-suites sg_sol_021_authority_account_user_authority_is_u
skill skills/solana-account-validation/SKILL.md checklist satisfied:
- SPL accounts assert `owner == token_program`, the right `mint`, and the expected authority
- Every account that authorises a privileged action is typed `Signer<'info>` (or its
medium Single-source price oracle.spotPrice() decides value in priceOf() solidity/HyperVault.sol:74 (priceOf) conf 0.65 · OR-003 · hyperevm
priceOf() trusts one oracle call (oracle.spotPrice) for a value-deciding calculation, with no deviation check, no second source and no circuit breaker.On HyperEVM the HyperCore spot read is itself a consensus input, so treat unit/exponent mismatches and asset-alias confusion as the realistic failure mode. If that single print is wrong - manipulated, stale, or a different asset's unit - every dependent decision is wrong in the same direction.
return oracle.spotPrice(asset);
- Identify the oracle's own trust path (a small set of attesters, a single pair, a low-liquidity venue).
- Move or misreport that one input.
- Call the value-deciding entry point while the whole system believes the bad number.
- Cross-check a second independent source and revert if they differ by more than a threshold.
- Validate the returned exponent/units before using the number.
- Add a per-block max-move (circuit breaker) and an explicit pause role.
PoC stub: pocs/sg-evm-022_sg_evm_022_single_source_price_o.t.sol
run: forge test --match-contract SgEvm022SingleSourcePriceOracleSpotpriceHarness -vvvv
skill skills/oracle-price/SKILL.md checklist satisfied:
- A second, independent source or a max-deviation circuit breaker exists for anything that
- Units/exponents are asserted (1e8 Chainlink vs 1e18 pool decimals vs HyperCore asset
medium Unchecked arithmetic on `shares` in deposit() solana/vault/programs/vault/src/lib.rs:33 (deposit) conf 0.60 · MATH-002 · solana
deposit() assigns `ctx.accounts.user_vault.shares = ctx.accounts.user_vault.shares + shares` from an arithmetic expression with no `checked_*`/`Saturating` wrapper. Token amounts are `u64`; in an anchor build with overflow checks on, an overflow panics the instruction (DoS of the account), and anywhere the checks are off - or the value is derived from a cast - it wraps around and produces a tiny or absurd balance.
corroborated independently by 1 other detector(s), 2 rule(s): ACCT-001 (SG-SOL-012), ACCT-002 (SG-SOL-006) (annotation only — does not change this grade)
ctx.accounts.user_vault.shares = ctx.accounts.user_vault.shares + shares;
- Feed an amount near u64::MAX (multi-token deposits, or a manipulated `limiter` multiplier).
- The accumulation wraps; your recorded share count becomes attacker-controlled.
- `checked_add`/`checked_mul` with a program error on overflow.
- Bound instruction inputs (`require!(amount < MAX_DEPOSIT, ...)`) before arithmetic.
- Cast through `u128` for intermediates.
PoC stub: pocs/sg-sol-022_sg_sol_022_unchecked_arithmetic_.rs
run: cargo test --features test-suites sg_sol_022_unchecked_arithmetic_on_shares_in_dep
skill skills/solana-math/SKILL.md checklist satisfied:
- Arithmetic uses `checked_add/sub/mul/div` or `Saturating`. If the workspace sets
- Intermediates are widened (`u128`) and narrowed only at the end, after bounds checks.
medium Unchecked arithmetic on `shares` in withdraw() solana/vault/programs/vault/src/lib.rs:55 (withdraw) conf 0.60 · MATH-002 · solana
withdraw() assigns `user_vault.shares = user_vault.shares - amount` from an arithmetic expression with no `checked_*`/`Saturating` wrapper. Token amounts are `u64`; in an anchor build with overflow checks on, an overflow panics the instruction (DoS of the account), and anywhere the checks are off - or the value is derived from a cast - it wraps around and produces a tiny or absurd balance.
corroborated independently by 2 other detector(s), 2 rule(s): AUTH-003 (SG-SOL-021), SIGNER-002 (SG-SOL-003) (annotation only — does not change this grade)
user_vault.shares = user_vault.shares - amount;
- Feed an amount near u64::MAX (multi-token deposits, or a manipulated `limiter` multiplier).
- The accumulation wraps; your recorded share count becomes attacker-controlled.
- `checked_add`/`checked_mul` with a program error on overflow.
- Bound instruction inputs (`require!(amount < MAX_DEPOSIT, ...)`) before arithmetic.
- Cast through `u128` for intermediates.
PoC stub: pocs/sg-sol-023_sg_sol_023_unchecked_arithmetic_.rs
run: cargo test --features test-suites sg_sol_023_unchecked_arithmetic_on_shares_in_wit
skill skills/solana-math/SKILL.md checklist satisfied:
- Arithmetic uses `checked_add/sub/mul/div` or `Saturating`. If the workspace sets
- Intermediates are widened (`u128`) and narrowed only at the end, after bounds checks.
medium Raw ecrecover without malleability checks in claimTreasuryGrant() solidity/HyperVault.sol:88 (claimTreasuryGrant) conf 0.60 · SR-004 · hyperevm
claimTreasuryGrant() calls `ecrecover` directly. Secp256k1 signatures are malleable: (v, r, s) and (v', r, n-s) both recover the same address. Without a high-s rejection, and without restricting v to {27,28}, the same authorisation appears as two different hashes, which breaks any de-duplication or replay map keyed on the payload.
corroborated independently by 2 other detector(s), 3 rule(s): CEI-001 (SG-EVM-003), EC-001 (SG-EVM-010), EC-003 (SG-EVM-024) (annotation only — does not change this grade)
address signer = ecrecover(doc, v, r, s);
- Take a used signature, flip s to n - s and adjust v.
- If dedupe keys on the raw payload rather than the recovered digest, the second variant passes.
- Use OpenZeppelin `ECDSA.tryRecover`/`recover` which enforces low-s and v in {27,28}.
- Key replay maps on the eip-712 digest, not on the submitted bytes.
PoC stub: pocs/sg-evm-023_sg_evm_023_raw_ecrecover_without.t.sol
run: forge test --match-contract SgEvm023RawEcrecoverWithoutMalleabilityCHarness -vvvv
skill skills/signature-replay/SKILL.md checklist satisfied:
- Malleability is handled by a library (`ECDSA.recover`/`tryRecover`, enforced low-s,
low `realloc::payer = vault_config` is not a funded signer in Withdraw solana/vault/programs/vault/src/lib.rs:169 conf 0.60 · AUTH-002 · solana
`Withdraw` resizes an account and charges the rent delta to `vault_config`, which is not declared in this struct. Solana requires the rent payer of a resize to be a writable, funded signer; a PDA or program-owned account cannot pay it, so the resize fails (availability) or - if the slot can be filled by whoever calls - the cost lands on a third party. This is a design note rather than a proof of theft, which is why it is graded low/medium.
corroborated independently by 1 other detector(s), 1 rule(s): ACCT-003 (SG-SOL-019) (annotation only — does not change this grade)
#[account(mut, seeds = [b"vault", vault_config.key().as_ref()], bump = vault_config.bump, realloc = 8, realloc::payer = vault_config, realloc::zero = false)]
- Trigger the realloc path repeatedly so the charged account bleeds lamports it never agreed to.
- Or front-run the growth so the rent cost lands on the victim's account.
- `#[account(mut)] payer: Signer<'info>` as `realloc::payer`, verified as the tx fee payer.
- Prefer fixed `space` at init over dynamic realloc in value-critical paths.
- If the PDA must fund growth, fund it explicitly with a signed transfer first.
PoC stub: pocs/sg-sol-024_sg_sol_024_realloc_payer_vault_c.rs
run: cargo test --features test-suites sg_sol_024_realloc_payer_vault_config_is_not_a_f
skill skills/solana-account-validation/SKILL.md checklist satisfied:
- SPL accounts assert `owner == token_program`, the right `mint`, and the expected authority
low Narrowing cast `as u64` on an amount in slash() solana/vault/programs/vault/src/lib.rs:67 (slash) conf 0.50 · MATH-004 · solana
slash() casts through a narrower integer while computing token amounts. Combined with the multiply/divide ordering above, silent truncation is the second half of the precision bug: the math can be right in `u128` and wrong once cast back.
corroborated independently by 2 other detector(s), 2 rule(s): CPI-003 (SG-SOL-015), SIGNER-002 (SG-SOL-004) (annotation only — does not change this grade)
let shares = (report.amount as u64) * vault.limiter;
- Choose inputs whose intermediate exceeds the target width.
- The truncated value becomes the share/balance the program trusts.
- Keep intermediates in `u128` and cast only at the end, after bounds checks.
- Add a unit test around `u64::MAX / 2` sized amounts.
PoC stub: pocs/sg-sol-025_sg_sol_025_narrowing_cast_as_u64.rs
run: cargo test --features test-suites sg_sol_025_narrowing_cast_as_u64_on_an_amount_in
skill skills/solana-math/SKILL.md checklist satisfied:
- `as` casts are reviewed for truncation (`u128 as u64`, `i64 as u64`).
- Multiplication happens **before** division; division-first expressions are precision-loss
low Native `.transfer()` gas-stipend transfer in claimTreasuryGrant() solidity/HyperVault.sol:90 (claimTreasuryGrant) conf 0.50 · EC-003 · hyperevm
claimTreasuryGrant() forwards value with `.transfer/send()`, which forwards exactly 2300 gas. That is enough for a plain wallet but not for most smart-contract recipients, so payments to contracts (multisigs, other vaults, account-abstraction wallets) fail - and on HyperEVM the stipend assumption has changed more than once. Reported as low because it is availability, not theft.
corroborated independently by 2 other detector(s), 5 rule(s): CEI-001 (SG-EVM-003), SR-001 (SG-EVM-017), SR-002 (SG-EVM-002), SR-003 (SG-EVM-020), SR-004 (SG-EVM-023) (annotation only — does not change this grade)
payable(msg.sender).transfer(amount);
- No attacker needed: legitimate users with contract wallets simply cannot be paid.
- If the result is unchecked, the contract marks the payout as done anyway.
- Prefer withdrawal patterns the user triggers, with `Address.sendValue` + checked result.
- Never assume 2300 gas is enough for every recipient.
PoC stub: pocs/sg-evm-024_sg_evm_024_native_transfer_gas_s.t.sol
run: forge test --match-contract SgEvm024NativeTransferGasStipendTransferHarness -vvvv
skill skills/external-calls/SKILL.md checklist satisfied:
- The 2300-gas stipend of `send`/`transfer` is acceptable for every plausible recipient
Heuristic static analysis: findings below 0.6 confidence are review prompts, not verdicts. PoC files are commented skeletons against synthetic samples; no live exploit code is emitted. The adjudicator records detector agreement but is annotation-only: it never raises a severity or a confidence, and sites where detectors disagree stay as separate findings rather than being averaged.