Skip to content
Request an audit

Partial signature replay/frontrunning attack on session calls

TitlePartial signature replay/frontrunning attack on session calls
Reward$19609, Unique
ContestSequence - 7 October 2025 on Code4rena
Authormontecristo
ContextSig 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.

solidity
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:

solidity
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.

Full Report
Codebase

Message on Telegram All 62 posts