Exploit post-mortems
/
New Market Trading
Multi-chain
Access control

New Market Trading's $4M Loss and How Olympix Would Have Prevented It

Loss
Loss
Exploit date
May 25, 2026

Date: May 25, 2026

Chain: Ethereum, Base, Arbitrum

Loss: about $3.98M drained from 88 client Safes

Class: missing caller check on a Safe module's cross-chain entry point

At 06:11:37 UTC on May 25, 2026, a wallet on Base sent a batch of transactions to a contract named SquidRouterModule. Thirty-four seconds later the same wallet started on Ethereum, and Arbitrum followed. Each transaction told the module to approve, authorize and swap the full balance of a client's Gnosis Safe into a token called "u" that only the attacker could sell, and the module did it, Safe after Safe, without a single owner signature.

The module belonged to New Market Trading (NMT), a wealth management platform that kept each client's funds in that client's own Safe and let an automated delegate trade on their behalf. According to the Rekt analysis, 88 Safes lost about $3.98M across three chains. The module checked that a delegate had permission to trade a Safe, but it read the delegate's name from the message instead of checking who was actually calling, and it accepted a public string as proof the message came from a trusted bridge.

Client Safes, a Delegate Bot, and One Module

NMT's model kept custody with its clients. Each user's assets sat in an individual Gnosis Safe, the multisig smart wallet most of DeFi uses for treasury and personal custody, and an off-chain delegate bot executed swaps, approvals and yield strategies inside those Safes. Safes support modules, separate contracts that the owners enable and that can then execute transactions from the Safe without collecting owner signatures each time. That's what made the automation possible, and it's why a module has to be at least as careful about who it listens to as the Safe's owners are.

NMT's permissions lived in a PermissionsManager contract, which recorded, Safe by Safe, which delegate could do what through which module: approve tokens, swap, wrap and unwrap ETH, or bridge. SquidRouterModule added cross-chain actions through Squid's router and Axelar's messaging network. A delegate could bridge tokens out of a Safe on one chain and attach follow-up actions for the destination chain, which the module would then run against the destination Safe when the bridged tokens arrived.

The name caused its own confusion. Squid's co-founder Fig wrote on X that the contract was unrelated to Squid and that the team didn't know who wrote or deployed it, and The Block reported Squid's statement that its own router and core contracts were untouched. Safe Labs CEO Rahul Rumalla said Safe's core contracts were fine and only Safes that had enabled this module were affected.

Two Doors Into the Same Actions

The module had two ways to reach its action handler. The same-chain door, executeSameChainActions(), works out who the delegate is from the caller. In the verified BaseModule source on Blockscout, _getDelegate() returns msg.sender, or the current delegate of NMT's DelegateBundler when the bundler is the caller, so only an address with recorded permissions could get past the checks that follow.

The cross-chain door came from Axelar. The module inherits AxelarExpressExecutableWithToken, which exposes expressExecuteWithToken(). Axelar's express path exists so that a relayer can front tokens on the destination chain and get repaid later when the real cross-chain message is confirmed, so by design it runs the destination logic before Axelar's gateway has validated anything. Rekt describes that entry point as the way the calls reached the module rather than the root cause. In the inherited Axelar code, expressExecuteWithToken() hands the caller's arguments straight to the module's own _executeWithToken(), so the module alone decides what to trust.

From the verified SquidRouterModule source on Blockscout, this is what the module trusted:

function _executeWithToken(
    bytes32,
    string calldata,
    string calldata sourceAddress,
    bytes calldata payload,
    string calldata tokenSymbol,
    uint256 amount
) internal override {
    // Verify source chain sender address
    address srcAddress = Strings.parseAddress(sourceAddress);
    require(srcAddress == squidRouter, InvalidSourceAddress(srcAddress));

    IERC20 token = IERC20(_getTokenAddress(tokenSymbol));

    _processPayload(token, amount, payload);
}

function _processPayload(
    IERC20 bridgedToken,
    uint256 bridgedTokenAmount,
    bytes calldata payload
) internal {
    (address module, address safe, address delegate, ActionsExecutionParams memory params) = abi.decode(
        payload,
        (address, address, address, ActionsExecutionParams)
    );

    require(module == address(this), InvalidModuleAddress(module));

    // Send all bridged tokens to the safe
    bridgedToken.safeTransfer(safe, bridgedTokenAmount);

    _handleActions(safe, delegate, params);
}

The comment says the first check verifies the source chain sender, but sourceAddress is a string argument to the function. On the express path, the caller writes it. Parsing it into an address and comparing it with the immutable squidRouter address only proves that the caller knows Squid's router address, which is public on every block explorer.

_processPayload() then decodes three addresses from the same caller-written payload: the module, the Safe, and the delegate. The module check only confirms the payload names this contract. The Safe is whatever Safe the caller chose. And the delegate is whatever address the caller typed in, which _handleActions() passes to every action handler. Each handler calls _checkPermission(safe, delegate, ...), so the module faithfully confirmed that the named delegate had swap and approval rights over the named Safe. It never confirmed that the named delegate was the one asking.

That's the teaching moment in this exploit. A permission check is only as good as the identity it checks, and the module took that identity from the request instead of from msg.sender. The same contract got it right on the same-chain door, where _getDelegate() reads the caller, and got it wrong on the cross-chain door, where the delegate arrives as data. Rekt and QuillAudits both point to the same missing line, a requirement that msg.sender equal the delegate.

Three Chains in One Morning

The attacker prepared before touching a single Safe. According to Rekt, the main operator wallet 0x7c82...3BB8 deployed the fake token "u" and paired it against each real asset the victims held in 19 Uniswap V3 pools, with the attacker as the only liquidity provider. PeckShield traced the attacker's 2.1 ETH of starting funds to Tornado Cash, and the delegate addresses the payloads would name were readable from NMT's PermissionsManager like any other on-chain state.

The drain on Base began at 06:11:37 UTC in block 46450075, with transactions from the co-operator wallet 0x9BDC...5b91 through the attack contract 0x2D45...70bc. Base's explorer shows what each one did. In one early transaction, the contract called the module four times for a single Safe: it wrapped 20.58 ETH sitting in the Safe into WETH, then swapped about 1.69 cbBTC, 31,773.79 USDC, 13,095.00 apyUSD and the 20.58 WETH into "u" through the attacker's pools. The next transaction in the same block did the same to another Safe for about 1.63 cbBTC, 40,684.74 USDC, 22,017.34 apyUSD and 0.88 WETH.

Ethereum followed at 06:12:11 UTC through a second attack contract, 0xe1d5...265a. The transaction DeFiHackLabs reproduces ran at 06:25:47 UTC in block 25170513 and swapped one Safe's entire 5,805.95 USDC for 5,612.69 "u". Another, at 06:17:59 UTC, took 0.2536 WBTC, 0.2936 wTAO and 0.0221 WETH from a different Safe. In every case, the Safe received "u" and the attacker's pools received the real asset.

Common Prefix counted 308 successful drain transactions across the three chains. Rekt reports that Base accounted for about 71% of the value, Ethereum about 27% and Arbitrum about 2%, and the largest items in the haul were about 1.18M USDC, 12.44 cbBTC, 181 WETH, 371K apyUSD, 257K gtUSDCp and 97K cbXRP. QuillAudits describes the drain as lasting under 15 minutes, but Ethereum's explorer shows the main operator still sending the same call through a third contract, 0xfac7...760c, until about 09:48 UTC.

The damage stopped well short of the module's full reach. Rekt counts 229 Safes that had active permissions and weren't touched, including 127 of the 151 exposed Safes on Ethereum and 102 of the 148 on Base.

From Nineteen Pools to One DAI Wallet

Once the Safes were swapped out, the attacker withdrew liquidity from the 19 pools, which returned the real assets the victims had paid in. According to Rekt and QuillAudits, the proceeds were consolidated across chains through Relay, converted to DAI and swept into a single holding wallet, 0xA447...4859, which PeckShield put at about 3.07M DAI.

NMT's CEO Frank Hepworth published a statement on May 27 saying the funds "belong to our clients" and offering a white-hat deal: return 90% to an NMT recovery address by May 30 and keep 10%, with a written commitment to no prosecution or civil action. QuillAudits also records an on-chain offer for the return of 80%. Rekt reported no response from the attacker.

The DAI sat still for more than four months. On October 5, 2026, the holding wallet sent 3,078,821.68 DAI and 2.51 ETH to a fresh address, 0x60c3...F46d. As of October 23, 2026, that address still held the DAI, and the old holding wallet was receiving a stream of look-alike "ETH" transfers from addresses beginning 0x60c3, a common address-poisoning pattern.

Where the Review Ended and the Module Began

NMT's system was reviewed. Burra Security audited NMT's V1 contracts in October 2025, and Burra told Rekt that V1 didn't include SquidRouterModule, which arrived with V2 after the review. Ethereum's explorer shows the module was created on February 25, 2026, and Rekt dates the first Safe permissions for it to February 24 on Base and February 26 on Ethereum. For about three months, every Safe that granted the module permissions was exposed.

That's a familiar shape. An audit covers the code it's given, and the code that failed here was written afterward and sat at the seam between two pieces that each look reasonable alone: an Axelar base contract that deliberately skips gateway validation on the express path, and a permissions system that correctly answers whether a given delegate may act on a given Safe. The flaw lived in the few lines connecting them, where the module decided which delegate to ask about.

How Olympix Catches a Delegate Supplied by the Caller

Olympix reads a contract the way an attacker does, as states, transitions and entry points. In SquidRouterModule, the state that matters is the balance of every Safe that enabled the module, and the transitions that change it are the action handlers that call execTransactionFromModule. The engine maps every entry point that reaches those handlers, from executeSameChainActions() and executeSquidRouterBridgeWithActions() to the gateway-validated executeWithToken() and the inherited expressExecuteWithToken().

From there it narrows. A signal is a pattern in the code that marks a path as dangerous, and Olympix's static analysis surfaces the one here: an externally callable path that reaches a privileged Safe call where the identity passed to the permission check is decoded from calldata, while the sibling path derives the same identity from msg.sender. A second signal sits next to it, an authentication check that compares a caller-supplied string to a public constant. Of all the paths into the module, those two signals single out the express path, the only feasible route from an arbitrary caller to a Safe transaction that never touches msg.sender.

The invariant the module depended on fits in one sentence: an action can only run against a Safe if the address that requested it holds the matching permission for that Safe. The module writes that rule into its own same-chain path, which is how BugPoCer infers it. BugPoCer runs Counterexample-Guided Invariant Refutation, working outward from the module, its permission checks and the Safe interface as one system, and executing the flagged paths until one breaks the rule.

The counterexample for this module is short. An address with no permissions calls expressExecuteWithToken() with Squid's router address as the source string and a payload naming a Safe and that Safe's real delegate. The module passes both checks, then approves and swaps the Safe's balance for whatever the payload specifies. The minimal conditions are just that the Safe has the module enabled and its delegate holds approval and swap permissions. BugPoCer returns that sequence with a generated Forge test that replays it, which is the May 25 drain, produced in development instead of against 88 client Safes.

Olympix has flagged this class before in other codebases: a privileged action guarded by a permission check that never ties the permission to the caller. Running on every merge, the engine would have raised it on the commit that added SquidRouterModule, before February's deployment and before any Safe granted it permissions.

Identity Comes From the Caller

Modules sit inside custody, and any input a module trusts to decide whose authority it's using has to come from msg.sender or from a cryptographic proof, never from the calldata the caller writes. When a contract has a careful path and a fast path into the same actions, both need the same answer to the question of who's asking. If your contracts carry permissions on behalf of users, see how Olympix traces every entry point to the actions it can unlock and hands back a runnable counterexample for any path that skips the caller, and book a technical walkthrough.

On-Chain References

Protocol: New Market Trading (SquidRouterModule Safe module)

Chain: Ethereum, Base, Arbitrum

Date: May 25, 2026, from 06:11:37 UTC (Base) and 06:12:11 UTC (Ethereum)

Loss: about $3.98M from 88 Safes (Common Prefix, via Rekt)

SquidRouterModule (Ethereum): 0x1f1d...23ca

SquidRouterModule (Base): 0x1f1d...23ca

Module creation tx (Ethereum): 0x3391...db39

PermissionsManager: 0x03B8...4cB7

Main operator: 0x7c82...3BB8

Co-operator: 0x9BDC...5b91

Attack contract (Ethereum): 0xe1d5...265a

Attack contract (Ethereum, later): 0xfac7...760c

Attack contract (Base, Arbitrum): 0x2D45...70bc

Fake "u" token: 0xe6Ff...3512

Early Base drain tx: 0xf0f9...4dad

Base block: 46450075

Ethereum exploit tx (DeFiHackLabs PoC): 0x59d1...9aea

Ethereum block: 25170513

Ethereum exploit tx (WBTC, wTAO, WETH): 0x2d52...bfeb

DAI holding wallet: 0xA447...4859

DAI transfer out (October 5, 2026): 0x9a47...b076

Current DAI address: 0x60c3...F46d

NMT bounty message tx: 0xa7db...ee70

Sources

Rekt: New Market Trading

QuillAudits: New Market Trading Exploit Analysis

The Block: Squid distances itself from $3.2 million third-party module exploit

DeFiHackLabs: New Market Trading exploit reproduction

Blockscout: SquidRouterModule verified source

See what Olympix finds on your own code

Send us a repo that's already been audited, and we'll run Olympix at the same commit and show you what comes back.