Partial signature replay/frontrunning attack on session calls
| Title | Partial signature replay/frontrunning attack on session calls |
|---|---|
| Reward | $19609, Unique |
| Contest | Sequence - 7 October 2025 on Code4rena |
| Author | montecristo |
| Context | Sig Replay |
When a session call with BEHAVIOR_REVERT_ON_ERROR behavior fails, the entire execution reverts but the signature remains valid, since nonce is not yet consumed. Attackers can forge a valid partial signature from failed multi-call session, executing partial calls that were never intended to run independently.
Moreover, if an attacker has access to mempool, they can frontrun a multi-call session to execute only a subset of calls to either grief the legitimate call, or inflict financial damage to the wallet owners.
The Calls contract consumes nonces before signature validation and execution. When a call fails with BEHAVIOR_REVERT_ON_ERROR, the entire transaction reverts, including nonce consumption.
36: function execute(bytes calldata _payload, bytes calldata _signature) external payable virtual nonReentrant {
37: uint256 startingGas = gasleft();
38: Payload.Decoded memory decoded = Payload.fromPackedCalls(_payload);
39:
40:@> _consumeNonce(decoded.space, decoded.nonce);
41: (bool isValid, bytes32 opHash) = signatureValidation(decoded, _signature);
42:
43: if (!isValid) {
44: revert InvalidSignature(decoded, _signature);
45: }
46:
47:@> _execute(startingGas, opHash, decoded);
48: }
62: function _execute(uint256 _startingGas, bytes32 _opHash, Payload.Decoded memory _decoded) private {
63: bool errorFlag = false;
64:
65: uint256 numCalls = _decoded.calls.length;
66: for (uint256 i = 0; i < numCalls; i++) {
...
85: if (call.delegateCall) {
...
99: } else {
100: (success) = LibOptim.call(call.to, call.value, gasLimit == 0 ? gasleft() : gasLimit, call.data);
101: }
102:
103: if (!success) {
...
110: if (call.behaviorOnError == Payload.BEHAVIOR_REVERT_ON_ERROR) {
111:@> revert Reverted(_decoded, i, LibOptim.returnData());
112: }
...
120: emit CallSucceeded(_opHash, i);
At this point, the signature is publicly visible and still valid, because nonce usage is not recorded on Calls contract yet.
Session signatures are validated per-call using individual call hashes, which makes partial signature replay attack possible:
136: for (uint256 i = 0; i < callsCount; i++) {
...
162: // Read session signature and recover the signer
163: {
164: bytes32 r;
165: bytes32 s;
166: uint8 v;
167: (r, s, v, pointer) = encodedSignature.readRSVCompact(pointer);
168:
169:@> bytes32 callHash = hashCallWithReplayProtection(payload, i);
170: callSignature.sessionSigner = ecrecover(callHash, v, r, s);
171: if (callSignature.sessionSigner == address(0)) {
172: revert SessionErrors.InvalidSessionSigner(address(0));
173: }
174: }
175:
176: sig.callSignatures[i] = callSignature;
The vulnerability also opens a second attack vector: front-running. A malicious party can watch the mempool for a multi-call session and deliberately front-run it to execute only a subset of the expected calls, either griefing the session or causing real financial damage to the wallet owner.
Alpha: this is a common bug in signature schemes — check that a batch signature cannot be partially executed. Note that the transaction reverts if the signature fails, so the individual signatures can be executed after the reverting transaction; front-running is not even required.
Conclusion
This finding would earn you $19609, and needs only knowledge of the partial-execution attack vector.