Pre-Mortem is a weekly series by Kostas Ferles, CTO of AuditHub. This one looks at a route that checked something after a low-level call and still let the call itself go unvalidated, and why that distinction is what the detector is built to catch. New breakdown every week. Find Kostas at @KFerles.
The audit firm Veridise just published the auditor’s view of this bug. Its CEO, Jon Stephens, works through why the function’s validation was missing in the first place (the Auditor’s Take This post covers the AuditHub side of the same bug: the exact reasoning Jon lays out by hand, running automatically, on a schedule, before anyone has to notice it manually.
Socket is a cross-chain bridge, and in January 2024 an attacker used one of its swap routes to drain roughly $3.3 million from wallets that had approved its gateway contract. The drain was due to a call hijacking. The route let the caller supply the arguments to a low-level call, so an attacker pointed it at a token of their choosing and had it execute a transferFrom on their behalf.
The route did something Arcadia’s rebalancer, the subject of an earlier Pre-Mortem, did not. Arcadia had no validation at all. Socket had a require in the code, with an error string, sitting directly under the low-level call. A human reviewer scanning the function sees that and reasonably relaxes. A static analysis detector is more thorough and more disciplined, because it checks something narrower than whether a check exists. It checks whether the specific value that decides the call was ever constrained, and here it was not.
TL;DR
- Socket’s route let the caller choose the call’s target and data
- A
requireexisted, but it checked the swap’s outcome, not the operation - Vanguard flags calls by testing whether inputs are constrained, not whether any check exists
- Running the detector on every commit catches the route before it ships
What performAction Actually Did
Route 406 was a function called performAction, deployed to Socket’s gateway three days before the exploit. Its job was narrow: unwrap a wrapped token into its native form, or the reverse. Here is the branch that unwraps:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
function performAction( address fromToken, address toToken, uint256 amount, address receiverAddress, bytes32 metadata, bytes calldata swapExtraData ) external payable override returns (uint256) { // ... native-to-wrapped branch omitted ... } else { _initialBalanceTokenOut = address(socketGateway).balance; ERC20(fromToken).safeTransferFrom(msg.sender, socketGateway, amount); (bool success, ) = fromToken.call(swapExtraData); if (!success) { revert SwapFailed(); } _finalBalanceTokenOut = address(socketGateway).balance; require( (_finalBalanceTokenOut - _initialBalanceTokenOut) == amount, "Invalid wrapper contract" ); } } |
Two values here come straight from the caller with nothing constraining them: fromToken, which becomes the address the low-level call goes to, and swapExtraData, which becomes the calldata sent to it. Together they decide what operation actually runs. In the intended flow, fromToken is the token being unwrapped, and swapExtraData calls that token’s own withdraw function.
An attacker supplied a different token as fromToken and an encoded transferFrom as swapExtraData, naming a victim as the source and themselves as the recipient. Because the route runs as the gateway, that transferFrom executed against an address the victim had actually approved.
The require at the end is real and it does something. It checks that the gateway’s native ETH balance moved by exactly amount. That is a sanity check on the result: for a one-for-one unwrap, the ETH received should equal the tokens sent in, and the error string, Invalid wrapper contract, says what it was written to catch. It says nothing about which operation produced that result. The attacker passed an amount of zero, so the check compared zero against zero and passed while the transferFrom moved no ETH at all.
What Is the AuditHub Approach
The AuditHub Approach is how we systematically catch the classes of bugs that manual review struggles to reason about exhaustively. Work out the reasoning once, encode it as an automated check, and it runs on every commit from then on without anyone having to remember it. The approach combines custom static analysis with specification-guided fuzzing, and which of the two carries a given case depends on the type of reasoning needed.
This one is carried entirely by static analysis, so that is what the rest of this post is about. Custom static analysis means writing detectors for the specific patterns you care about, instead of relying on generic rules. Some are written against a single protocol’s logic. This one is written against a vulnerability class, which is why the detector that ran on Arcadia runs here unchanged. For Socket, the question a detector needs to settle is whether fromToken and swapExtraData pass through anything that constrains them before they reach the call. That is a structural question about the source, and it can be answered without running the code.
What the Detector Actually Asks
This is a property of the source, and it can easily be detected without running the code. We published the detector for this class alongside the Arcadia Pre-Mortem. It is a custom detector written in the query language of Vanguard, our static analyzer.
In plain terms, it looks for any low-level call whose calldata traces back to an argument the caller supplied, and reports it when nothing on that path constrains the value. Here it is again with Socket’s actual code in front of us:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
FIND Function f, ExternalCall call IN f, Value calldata IN call.calldata, Argument arg IN calldata.backwardSlices, WHERE call.isLowLevelCall, arg.function == f, !(EXISTS RequireLike r IN arg.forwardSlices || EXISTS RequireLike r IN arg.interprocForwardSlices) AS affectedFn = f, vulnerableCall = call, argument = arg |
The clause that decides this case is the last one. It does not ask whether performAction contains a require anywhere in its body. It asks a narrower and more specific question: does anything constrain swapExtraData on its way from the function’s input to the low-level call. Socket’s require constrains a balance figure computed separately, with no path connecting it back to swapExtraData. So the answer is no, and the query flags the call, whether or not any other check happens to sit nearby.
That gap between what a human notices and what the query checks is the whole reason to run this as an automated pass. A reviewer sees the low-level call, then immediately sees a require with an error string right under it, and takes real comfort from that. The query does not take comfort from a require existing somewhere in the function. It only cares whether the one value that decides the call was ever constrained.
Worth stating the guarantee precisely, because this is the point where static analysis usually gets oversold. An empty result means something narrow and real. Across everything the query models, no caller-supplied value arrives at a low-level call payload without something constraining it along the way. That claim covers structure and nothing else. A finding is not a proven exploit, and working out whether one can be built means exercising the code, which is separate work. What the pass buys is cheap and worth having, since catching the shape stops depending on anyone noticing it.
When the Same Query Returns 54 Findings
On Arcadia the detector returned one finding, and the story ended there. On Socket it returns 54.
Four of the 54 carry no restriction on the caller at all. Two are the branches of the route that was exploited, and two more sit in a near-identical function that had been copied alongside it. The remaining fifty have some form of access control on the calling function.
A 54-item list is where a lot of teams quietly stop using a tool. Those fifty deserve a straight answer. They are not noise, and calling them false positives is the wrong instinct. Every one of them is a real instance of the shape, sitting behind an assumption that a privileged account does not get compromised. That assumption fails often enough that Veridise’s auditors would report these in an engagement at a lower severity instead of clearing them. The pattern is present in all fifty exactly as it is in the four, and what separates the groups is only who has to be compromised first.
That distinction is a judgment, and it is the half of the work a detector cannot do. What the platform can do is make the judgment cheap. Findings arrive grouped by severity in the Tool Findings table, and each one gets marked as a true or false positive. Once a root cause is marked invalid, related findings can be batch-actioned together, so fifty instances of one pattern collapse into a single decision, marked once rather than fifty times over.
The Commit That Added Route 406
Socket had been audited. Route 406 was deployed three days before it was drained, after those audits closed, and it was never in scope for any of them because it did not exist when they ran.
Nothing about that is unusual, and it is the situation this class keeps arriving in. A new integration, or a route added for a partner. Each is a small change that reads as reasonable, and each can introduce the shape. A review certifies the code that existed on the day it ran, and the code does not stop moving on that day.
When we say continuous, we mean it more literally than most teams do. The detector has to sit inside the workflow itself, firing on pushes and on pull requests. It also has to be a required gate ahead of any significant decision, deployment being the most significant one a team makes. Connect the repository and the detector runs on the commit that introduces the route, surfacing the finding for whoever authored it, while the cost is still a review comment. Fix it, rerun the pass, and the merge goes through clean. The property never changes and the code never stops changing, so the check has to travel with the code.
Want this caught before it ships?
If a caller can name the target or shape the payload of a low-level call anywhere in your contracts, a check on the outcome afterwards buys you nothing against this class. The detector reports the call whether or not that check is present. Connect your GitHub repository and it runs on every commit, ahead of the route going live. Try AuditHub for free or book a demo.