crates/lint/docs/arbitrary-send-erc20-permit.md
from in transferFrom used with permitSeverity: High
ID: arbitrary-send-erc20-permit
Flags transferFrom / safeTransferFrom calls whose from argument is not provably
msg.sender (or address(this)) when the function also calls
token.permit(owner, address(this), …) for the same token and owner beforehand.
Detects, within a single function, the combination of:
permit(owner, spender, value, deadline, v, r, s) on token with
spender == address(this), andowner, where
owner cannot be proven equal to msg.sender or address(this).Both common sink shapes are recognised:
token.transferFrom(owner, to, amount) and
token.safeTransferFrom(owner, to, amount).Lib.safeTransferFrom(token, owner, to, amount), including the
Solady-shaped using SafeTransferLib for address form.The lint does not require the transfer amount to equal the permit value,
nor does it inspect deadlines or signatures — the dangerous combination is the
permit-then-arbitrary-transferFrom pattern itself, not the specific amounts.
Matching EIP-3156 flash-loan repayments (onFlashLoan followed by a pull-back of
amount + fee) are excluded.
The check is intraprocedural. It flags one permit-then-transferFrom flow inside a
single function body and correlates the token, owner, and spender by the underlying
variable, with the following normalisations applied to both sides of the correlation:
address(x)), interface / contract casts (IERC20(rawToken)),
payable(...) wraps, and parentheses are stripped;IERC20 t = token; ..., address from2 = from; ...) are
tracked as aliases, so the permit and the sink still correlate when one side is a copy;address(this) (e.g. address self = address(this); permit(..., self, ...))
and no-arg helpers whose body is return address(this); are recognised as the permit
spender;using SafeTransferLib for address member form is treated as a sink.Dead code after a top-level return / revert is skipped — including function bodies
whose modifier prefix definitely exits before _;. Inline
// forge-lint: disable-next-line(arbitrary-send-erc20-permit) suppresses a single sink.
Additionally supported correlations:
cfg.token.permit(...) then cfg.token.transferFrom(...))
match via a (base var, field name) key;SafeERC20.safePermit(token, ...) is treated as an EIP-2612
permit on the token argument when the receiver is a library;address(this) or msg.sender
by their declaration initializer or constructor body are recognised as such at
the start of every function;Patterns the check still does not classify as the permit-variant (the
underlying call may still be reported by arbitrary-send-erc20 when the sink
itself is unguarded) include permits issued inside a called helper / modifier /
parent contract.
Permits inside for / while loop bodies do not establish facts visible after
the loop (the analyzer treats their execution count as possibly zero), so a
transferFrom placed after the loop is not classified as the permit variant. A
transferFrom inside the same iteration as the permit is still flagged.
A permit followed by transferFrom is the textbook EIP-2612 flow, so it looks
safe. It is not safe when the token does not actually implement permit but
has a fallback function (the canonical example is WETH). On such tokens:
permit(...) is forwarded to the fallback and silently succeeds without
authorizing anything.from straight into transferFrom.The recommendation is to pin the supported token(s) at deploy time and verify they
implement permit correctly, or to require from == msg.sender so that, even if
the permit silently no-ops, only the caller's own balance is at risk.
If your code separately proves that permit succeeded (for example by reading
token.nonces(owner) before and after and reverting on no change) or restricts the
sink to a vetted token allowlist, review the finding and suppress with
// forge-lint: disable-next-line(arbitrary-send-erc20-permit).
function pullWithPermit(
address from,
address to,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
token.permit(from, address(this), value, deadline, v, r, s);
token.transferFrom(from, to, value); // arbitrary-send-erc20-permit
}
function pullWithPermit(
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
// `from` is implicitly the caller — permit + transferFrom only touch the
// caller's own balance, even if `token` is a non-permit token with a fallback.
token.permit(msg.sender, address(this), value, deadline, v, r, s);
token.transferFrom(msg.sender, address(this), value);
}