Reproduced Exploit
Sablier — a Permit2 signature that does not name the recipient is front-run for 1000 tokens
The victim signs a Permit2 allowance for the proxy: token, amount 1000, spender = proxy. The signature does not name the recipient or the plugin. Anyone who can make the proxy delegatecall a plugin replays that signature and transferFroms the 1000 to themselves. The same signature fails permitWitne…
Chain
Other
Category
auth
Date
Jul 2023
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 54669-any-plugins-using-permit2-can-steal-funds-with-frontrunning. Standalone Foundry PoC and full write-up: 54669-any-plugins-using-permit2-can-steal-funds-with-frontrunning_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/auth/signature-validation · vuln/logic/missing-validation
Key info#
| Protocol | Sablier V2 (Permit2 inside a proxy plugin) |
| Finding | Cantina / Zach Obront, AuditVault 54669 |
| Vulnerable code | permit then transferFrom on a PermitSingle that does not bind the recipient |
| Harm | 1000 tokens move from the signer to the front-runner |
| Chain | local synthetic |
TL;DR#
The victim signs a Permit2 allowance for the proxy: token, amount 1000, spender = proxy. The signature does not name the recipient or the plugin. Anyone who can make the proxy delegatecall a plugin replays that signature and transferFroms the 1000 to themselves. The same signature fails permitWitness, which binds the recipient, and the victim keeps the 1000.
The vulnerable code#
function take(address recipient, bytes calldata signature) external {
Permit2.PermitSingle memory permitSingle = Permit2.PermitSingle({
token: address(token),
amount: uint160(AMOUNT),
expiration: type(uint48).max,
nonce: 0,
spender: address(this)
});
// @> VULN: the signature names the proxy, the token, and the amount, not the recipient.
permit2.permit(victim, permitSingle, DEADLINE, signature);
permit2.transferFrom(victim, recipient, uint160(AMOUNT), address(token));
}
address(this) is the proxy because take runs by delegatecall. The signed spender matches. recipient is chosen by the caller and is not in the hash.
Root cause#
PermitSingle commits the token, the amount, the nonce, the deadline, and the spender. It does not commit the action the allowance will be used for. A plugin that calls permit and then transferFrom can send the tokens anywhere. The recommended fix is permitWitnessTransferFrom, with the recipient (and the call) inside the signed witness.
Preconditions#
The victim holds 1000 tokens and has approved Permit2. They signed the PermitSingle for spender = proxy, nonce 0, amount 1000. The signature is known to the front-runner. The front-runner can make the proxy execute the plugin. No other privilege is required. The signature was precomputed for chain id 1 and this Permit2 address.
Attack walkthrough#
- The victim's balance is set to 1000 and Permit2's allowance is the standing max approval.
- The proxy delegatecalls the plugin with the signature and the front-runner as recipient.
permithashes the PermitSingle without the recipient.ecrecoverreturns the victim, so the allowance is opened.transferFromsends 1000 from the victim to the front-runner. The victim ends at 0.
The control calls permitWitness with the same signature and the front-runner as recipient. The witness hash includes the recipient, the signer does not recover, and the 1000 stays with the victim.
Diagrams#
Impact#
The front-runner receives 1000 tokens. The victim's balance goes from 1000 to 0. Binding the recipient in the signature stops the pull and the victim keeps the 1000.
Remediation#
Use permitWitnessTransferFrom and require the proxy to check that the witness contains the target and the recipient before forwarding the call. Do not accept a bare permit signature inside a plugin.
Sources#
- AuditVault finding: https://github.com/Auditware/AuditVault/blob/main/findings/54669-any-plugins-using-permit2-can-steal-funds-with-frontrunning.md
- Cantina report (Sablier, July 2023, Zach Obront): https://cdn.cantina.xyz/reports/cantina_sablier_jul2023.pdf
- Blamed pattern:
SablierV2ProxyTargetPermit2permit+transferFrom(finding quotesSablierV2ProxyTarget.solaround the permit2 transfer). Permit2 typed data follows Uniswap'sPermitSingle: https://github.com/Uniswap/permit2
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 54669-any-plugins-using-permit2-can-steal-funds-with-frontrunning_exp (evm-hack-registry mirror).
- AuditVault finding: 54669-any-plugins-using-permit2-can-steal-funds-with-frontrunning.
Alerts & third-party analyses
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.