Skip to content

Commit 165c1df

Browse files
authored
Merge pull request #1541 from Caneryy/feat/issue-1523
feat(stellar-portfolio-rebalancer): security-security-review-re-entrancy-risk-of-ext
2 parents eb29c20 + c618b81 commit 165c1df

1 file changed

Lines changed: 192 additions & 0 deletions

File tree

docs/SECURITY_AUDIT.md

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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

Comments
 (0)