|
| 1 | +# Security audit log — portfolio rebalancer contract |
| 2 | + |
| 3 | +This document records focused security findings for the Soroban portfolio |
| 4 | +rebalancer. Entries are append-only review notes; they do not replace a full |
| 5 | +third-party audit. |
| 6 | + |
| 7 | +| Date | Reviewer / issue | Area | |
| 8 | +| ---------- | ---------------- | ---- | |
| 9 | +| 2026-07-27 | #1523 | Re-entrancy via Reflector `lastprice` during rebalance | |
| 10 | + |
| 11 | +## Finding REENT-001 — External Reflector call ordering vs. portfolio state during `execute_rebalance` |
| 12 | + |
| 13 | +### Summary |
| 14 | + |
| 15 | +During `execute_rebalance`, the contract performs multiple cross-contract calls |
| 16 | +to the configured Reflector oracle (`ReflectorClient::lastprice`) **before** the |
| 17 | +updated `Portfolio` struct is persisted. Fee collection also performs SAC |
| 18 | +`token::transfer` interactions **before** that final persistence. This ordering |
| 19 | +does not match strict **checks → effects → interactions (CEI)** hardening for |
| 20 | +the rebalance path. Re-entrancy from a **malicious contract installed at |
| 21 | +`ReflectorAddress`** is theoretically possible at the Soroban VM level; practical |
| 22 | +fund-loss scenarios are largely mitigated by Soroban authorization rules and the |
| 23 | +expected deployment trust model, but CEI gaps remain relevant for defense in depth |
| 24 | +and non-standard tokens. |
| 25 | + |
| 26 | +**Severity:** **Medium** (configuration / trust-boundary and CEI ordering; not |
| 27 | +exploitable against the canonical Reflector deployment under normal admin |
| 28 | +practice) |
| 29 | + |
| 30 | +**Remediation status:** **Open** — documented only in this review (#1523). |
| 31 | +Implementation hardening (CEI reorder, optional reentrancy guard, oracle address |
| 32 | +allowlist) is tracked as separate engineering work on the same code path. |
| 33 | + |
| 34 | +### Scope and code references |
| 35 | + |
| 36 | +Primary logic: |
| 37 | + |
| 38 | +- `contracts/src/portfolio.rs` — `calculate_portfolio_value`, |
| 39 | + `build_rebalance_preview` (oracle reads in loops). |
| 40 | +- `contracts/src/lib.rs` — `execute_rebalance_internal` (orchestration, trade |
| 41 | + application, persistence). |
| 42 | + |
| 43 | +Reflector interface: `contracts/src/reflector.rs` (`lastprice` → external call). |
| 44 | + |
| 45 | +### External call inventory (rebalance execution path) |
| 46 | + |
| 47 | +For one successful `execute_rebalance` / `admin_force_rebalance` invocation, |
| 48 | +oracle interactions occur in this order: |
| 49 | + |
| 50 | +1. **`build_rebalance_preview`** (called from `execute_rebalance_internal`): |
| 51 | + - `calculate_portfolio_value`: one `lastprice` per entry in |
| 52 | + `current_balances`. |
| 53 | + - Per target allocation asset: another `lastprice` (staleness check + price |
| 54 | + map). |
| 55 | +2. **Optional slippage validation** (when `actual_balances` is non-empty): |
| 56 | + - `calculate_portfolio_value` again (another `lastprice` loop over balances). |
| 57 | + - Per allocation asset: `lastprice` again for slippage math. |
| 58 | + |
| 59 | +Read-only preview helpers (`preview_rebalance`, `check_rebalance_needed`, |
| 60 | +`get_drift_preview`, `get_portfolio_value_usd`) use the same |
| 61 | +`build_rebalance_preview` / `calculate_portfolio_value` patterns but do not |
| 62 | +mutate portfolio balances. |
| 63 | + |
| 64 | +### State writes vs. interactions (ordering) |
| 65 | + |
| 66 | +In `execute_rebalance_internal` (`lib.rs`), approximate ordering is: |
| 67 | + |
| 68 | +| Step | Action | Persistent portfolio state | |
| 69 | +| ---- | ------ | -------------------------- | |
| 70 | +| 1 | Load portfolio | Unchanged (read) | |
| 71 | +| 2 | Auth, cooldown, invariants | Unchanged | |
| 72 | +| 3 | `guard_ledger_timestamp` | **Instance** `LastTimestamp` updated | |
| 73 | +| 4 | Reflector calls (preview + optional slippage) | Portfolio **unchanged** | |
| 74 | +| 5 | Apply trades in memory; SAC fee `transfer` | Portfolio **not yet** written | |
| 75 | +| 6 | `set(Portfolio)` | **Committed** | |
| 76 | +| 7 | Events, NAV snapshot | Post-commit | |
| 77 | + |
| 78 | +Portfolio balance **effects** are held in a local `mut portfolio` until step 6, |
| 79 | +while **interactions** (Reflector, then fee transfers) already ran in steps 4–5. |
| 80 | +That is a classic CEI deviation: interactions precede the durable effects that |
| 81 | +should define re-entrancy-safe state. |
| 82 | + |
| 83 | +Cross-reference: **CEI hardening** for this path (persist planned state before |
| 84 | +external calls, or snapshot prices then execute without further oracle calls; |
| 85 | +move fee transfers after portfolio persistence; optional contract-local |
| 86 | +reentrancy mutex) is intentionally **out of scope for #1523** and should land |
| 87 | +via dedicated implementation issues on `execute_rebalance_internal` and |
| 88 | +`portfolio.rs` helpers. |
| 89 | + |
| 90 | +### Re-entrancy assessment |
| 91 | + |
| 92 | +**Mechanism.** Soroban allows nested contract calls. If `ReflectorAddress` |
| 93 | +points to attacker-controlled WASM, `lastprice` can invoke back into |
| 94 | +`PortfolioRebalancer` while the outer rebalance is mid-flight and before step 6 |
| 95 | +commits portfolio balances. |
| 96 | + |
| 97 | +**Trust boundary.** At initialization, admin sets `ReflectorAddress` |
| 98 | +(`initialize` in `lib.rs`). Production intent (see ADR 0002) is the official |
| 99 | +Reflector oracle contract, which does not re-enter callers. Risk materializes |
| 100 | +mainly when admin misconfigures the address, deploys to a compromised instance |
| 101 | +storage, or an upgrade swaps the oracle to malicious code. |
| 102 | + |
| 103 | +**Authorization on nested calls.** State-changing entrypoints require |
| 104 | +`require_auth` on steward, user, or admin (e.g. `execute_rebalance` → steward, |
| 105 | +`withdraw` → user). Soroban does not automatically propagate authorization from |
| 106 | +the outer user invocation through an untrusted callee; a nested |
| 107 | +`execute_rebalance` from a malicious oracle therefore **should revert** at |
| 108 | +`steward.require_auth()` unless the steward signed that nested invocation |
| 109 | +separately. That sharply limits classic “double rebalance” theft without |
| 110 | + additional auth bugs. |
| 111 | + |
| 112 | +**What re-entry can still do.** During oracle callbacks, persistent portfolio |
| 113 | +balances remain at pre-rebalance values, while the outer frame may already have |
| 114 | +passed cooldown (via `last_rebalance` on stored state) and updated |
| 115 | +`LastTimestamp`. A nested call that only uses **read** APIs (`get_portfolio`, |
| 116 | +`preview_rebalance`, valuation views) observes stale on-chain balances relative |
| 117 | +to the outer frame’s in-memory trade plan—useful for monitoring, not direct |
| 118 | +theft. Nested **authorized** calls (if an attacker could trick the steward into |
| 119 | +signing multiple invocations in one transaction) could interleave deposits, |
| 120 | +withdrawals, or a second rebalance; that is a broader transaction-composition |
| 121 | +concern, not unique to Reflector, but the oracle hook expands the window before |
| 122 | +portfolio persistence. |
| 123 | + |
| 124 | +**Token re-entrancy.** Fee `token::transfer` runs before portfolio `set`. Standard |
| 125 | +Stellar Asset Contract (SAC) tokens do not execute user hooks on transfer, so |
| 126 | +SAC fee collection is not a practical re-entrancy vector. Custom token contracts |
| 127 | +with callbacks would reintroduce CEI risk on the fee path; the portfolio assumes |
| 128 | +standard SAC assets for fee-bearing tokens. |
| 129 | + |
| 130 | +### Worst-case impact scenario (concrete) |
| 131 | + |
| 132 | +Assume: |
| 133 | + |
| 134 | +1. `ReflectorAddress` is malicious (admin mistake or compromised admin key). |
| 135 | +2. Steward executes one honest `execute_rebalance` transaction (single auth). |
| 136 | +3. On the first `lastprice` during `build_rebalance_preview`, the malicious |
| 137 | + oracle re-enters the portfolio contract. |
| 138 | + |
| 139 | +**Scenario A — nested `execute_rebalance` without new steward signatures** |
| 140 | + |
| 141 | +- Inner call fails at `steward.require_auth()`. |
| 142 | +- Outer call continues with prices chosen by the malicious oracle (oracle |
| 143 | + manipulation / stale quote abuse), not classic re-entrancy double-spend. |
| 144 | +- Impact: **incorrect trade sizing**, slippage check bypass if prices are |
| 145 | + inconsistent across sequential `lastprice` calls in the same tx, potential |
| 146 | + value drift vs. economic intent. Severity driven by oracle integrity, not |
| 147 | + re-entrancy alone. |
| 148 | + |
| 149 | +**Scenario B — steward signed a bundled transaction with multiple portfolio |
| 150 | +invocations** (e.g. social-engineered batch, compromised client) |
| 151 | + |
| 152 | +- Malicious oracle re-enters during outer preview; inner authorized |
| 153 | + `withdraw` / `execute_rebalance` runs while outer frame has not persisted |
| 154 | + new balances. |
| 155 | +- Outer frame then applies trades from preview computed on **pre-nested** storage |
| 156 | + state, while inner call may have changed balances or `last_rebalance`. |
| 157 | +- Impact: **accounting desync** between recorded `current_balances` and actual |
| 158 | + SAC holdings, double application of logical trade deltas, or cooldown / |
| 159 | + threshold bypass relative to user expectations. Worst case: **loss of user |
| 160 | + funds** proportional to portfolio size if withdrawals and rebalance trades |
| 161 | + compose maliciously in one ledger transaction. |
| 162 | + |
| 163 | +**Scenario C — canonical Reflector, CEI ordering only** |
| 164 | + |
| 165 | +- No hostile re-entry from oracle; remaining issue is ordering (fee transfer |
| 166 | + before persist). With SAC, **no observed exploit**; residual **Medium** as |
| 167 | + defense-in-depth and future token compatibility. |
| 168 | + |
| 169 | +### Recommendations (remediation backlog) |
| 170 | + |
| 171 | +| Priority | Action | Status | |
| 172 | +| -------- | ------ | ------ | |
| 173 | +| P1 | CEI hardening in `execute_rebalance_internal`: commit balance updates before external fee transfers; avoid further oracle calls after local state is finalized | Open | |
| 174 | +| P1 | Single price snapshot per asset per rebalance (no redundant `lastprice` in preview + slippage loops) | Open | |
| 175 | +| P2 | Document / enforce allowed Reflector contract IDs at `initialize` + upgrade checklist | Open | |
| 176 | +| P2 | Optional reentrancy guard (storage flag) around rebalance execution | Open | |
| 177 | +| P3 | Integration test with malicious mock oracle attempting nested portfolio calls | Open | |
| 178 | + |
| 179 | +### Accepted mitigations (no code change required for #1523) |
| 180 | + |
| 181 | +- Operational use of official Reflector contract address on each network. |
| 182 | +- Admin key hygiene and `ReflectorAddress` verification in |
| 183 | + [`CONTRACT_DEPLOYMENT_CHECKLIST.md`](CONTRACT_DEPLOYMENT_CHECKLIST.md). |
| 184 | +- Staleness checks in `build_rebalance_preview` (`REFLECTOR_PRICE_MAX_AGE_SECONDS` |
| 185 | + / 3600s window in preview path). |
| 186 | + |
| 187 | +### Tests reviewed |
| 188 | + |
| 189 | +Existing coverage exercises rebalance with benign mock Reflector (`contracts/src/test.rs`, |
| 190 | +`contracts/tests/integration_tests.rs`) but does **not** simulate malicious |
| 191 | +re-entering oracle behavior; adding such a test is listed in the remediation |
| 192 | +backlog above. |
0 commit comments