# A Safe lost $7.8 million to its own module

> A Gnosis Safe lost 7.8 million dollars in restaked ETH because an enabled module let a passing keeper point it at an attacker's own pool. The Safe itself worked. This kind of bug is old, and it comes down to who gets to choose the target.

- Source: https://defiloops.com/blog/a-safe-lost-7-8-million-to-its-own-module
- Published: 2026-09-15
- Updated: 2026-10-31
- Category: Security
- Tags: security, agentic-wallet, permissions, account-abstraction
- Author: DeFiLoops

---
<Callout type="note" title="Update">

Updated 31 October 2026: The guard that banned a list of actions was removed. The agent no longer has any raw call on your account, and its signing key now lives in a hardware enclave; what bounds the agent today is on [Your account and your keys](https://docs.defiloops.com/kernel). A spending limit is now a daily limit per token that you set. It is off by default, so nothing caps how much a step moves until you set one ([Your account and your keys](https://docs.defiloops.com/kernel)).

</Callout>

**In short:** if you keep money in a Safe, a shared wallet that needs more than one key to sign, and turn on extra modules for it, this is the case to know. On 15 September 2026 a Safe lost about $7.8 million in restaked ETH, and the attacker never had a key. A module the owner had turned on let anyone who called it pick which pool to send the funds through, and the attacker picked a pool they had just made. The Safe worked as built; the mistake was letting a stranger choose where the money goes.

On 15 September 2026, an Ethereum user lost about **$7.8 million** in restaked ETH out of a
multi-signature wallet. There is a memorable twist (a front-running bot took the money
before the attacker could), but the more useful part is the four seconds before it, when
the wallet did exactly what it was told to do, by someone who should not have been able to
tell it anything.

It is worth walking through, because it is one of the most common ways a smart account gets
emptied, and the flawed part always looks correct.

## What actually happened

The victim held roughly 2,900 rsETH (a liquid restaking token) inside a Gnosis Safe, in
its Aave-wrapped form. The attacker never had a key.

Instead, they used a **public keeper call** to point a custom Uniswap V4 liquidity module,
one the account had enabled, at a **pool the attacker had just created**. That pool
carried a malicious hook. The hook unwrapped the position into freely transferable rsETH,
and the module, acting with the Safe's authority, moved it.

<Callout type="danger" title="The Safe was not the bug">
  This is the first thing people get wrong. Gnosis Safe did what a
  multi-signature wallet is built to do: it let an enabled module act. The flaw was in the
  **module**, a third-party contract installed on top of the Safe that accepted a target
  from whoever called it. Blaming the wallet misses where the mistake lived, and the mistake
  is one anyone can repeat on any account framework.
</Callout>

## The bug is where the target came from

Without the restaking and the hooks, the bug is simple. A trusted module was handed a
destination (*which pool, which contract*) by its caller, and it acted on that destination
from inside its own privileged context. The validation it did was not enough, because the
attacker got to choose the one thing that decided where the money went.

<Compare left="What the module let a caller supply" right="What a caller should never supply" verdict>
  <Fragment slot="left">
    The pool to route through. A contract address, chosen at call time, by a public keeper
    anyone can invoke.
  </Fragment>
  <Fragment slot="right">
    Nothing that decides a destination. The venue is named from a list the owner installed;
    the recipient is the account, always; the module composes the call.
  </Fragment>
</Compare>

A module that takes its target from the caller trusts the caller to aim it, and on a public
keeper path the caller can be anyone. The attacker did not need to break in, because the
module let them steer.

## The twist, since it is a good one

The exploit transaction went into the public mempool, the waiting room where unconfirmed
transactions sit in the open. A generalised front-running bot named Yoink saw the pending
drain, copied it with a higher fee, and executed first in the same block. The original
attacker got nothing.

<Callout type="warn" title="Two failures for the price of one">
  The wallet was drainable because a module could be aimed by a stranger. The *attacker* was
  robbed because a valuable transaction is visible to everyone the moment it is broadcast.
  The victim lost the money either way. On a public chain, being first matters more than
  being the one who started it. That is its own reason not to leave a drain sitting in the
  mempool for anyone to claim.
</Callout>

## Can this happen to a DeFiLoops account?

**No. The specific move, aiming a trusted module at an attacker's contract, cannot be
expressed against a DeFiLoops account, and that is by design rather than by luck.** It does
not depend on more careful checks. The agent is never handed a target to aim.

<Spec title="Where the attacker's inputs have nowhere to go" rows={[
  ['Choose the pool / contract to route through', 'No such field. The agent submits typed parameters; the adapter composes the call and picks the target.'],
  ['Name a venue by address', 'Refused. Venues are named from the set the owner installed, never as an address in the instruction.'],
  ['Redirect the payout to yourself', 'The recipient is forced to the account and read from the account, never taken from the instruction.'],
  ['Prove you meant this account', 'The owner is read from the account. A valid agent signature is not proof the right account, or destination, was meant.'],
]} />

There is a broader reason too: **a production DeFiLoops account gives the agent no
raw-call path at all.** The drained Safe had a module that could execute a caller-chosen
call. Our agent cannot compose a call. It submits an amount, a venue named from a list and a
range, and a whitelisted adapter builds the transaction. No contract address ever comes from
the agent.

<StatRow>
  <Stat value="$0" label="an attacker-chosen target can move" accent />
  <Stat value="0" label="raw-call paths for the agent in production" />
  <Stat value="1" label="transaction to revoke the agent entirely" />
</StatRow>

## The attacker's plan, step by step

Assume the strongest realistic attacker short of stealing your owner key: they hold the
*agent* key, the one that signs the account's day-to-day instructions. Here is what they
would try, and where each step ends.

<Steps>
  <Step title="Point a module at a pool you control">
    There is no module that takes a pool from the caller. Trading runs through adapters that
    read the venue from the owner's installed set. An attacker-created pool is not in it, and
    an address in the instruction is refused, because it would let the caller pick any contract.
  </Step>
  <Step title="Fall back to a raw call and choose your own target">
    In production there is no raw-call path for the agent to fall back to. It is not
    installed, so this step cannot even be written.
  </Step>
  <Step title="Route through a legitimate adapter and redirect the funds">
    The recipient is forced to the account, the owner is read from the account, the venue
    comes from the owner's set, and the spend is capped per period. The adapter fixes every
    destination; the instruction sets none of them.
  </Step>
  <Step title="Install a module of your own, or change the account's root">
    Those selectors are refused. The agent can never install the kind of redirectable module
    that drained the Safe, become the owner, or hand the account away.
  </Step>
  <Step title="Meanwhile, the owner revokes the agent">
    One transaction, needing nobody's cooperation, and every future instruction from that key
    is refused before its target is even inspected, so the stolen key stops working the
    moment the owner acts.
  </Step>
</Steps>

A stolen agent key, against a production account, cannot aim anything at an attacker and
cannot take control. That is the entire security claim, and it holds against this exact
attack.

## What we do not claim

A security post that stopped there would be selling something. There are three limits,
and none of them is *this* attack.

<Callout type="warn" title="The residual risks, named">
  **Your owner key.** Whoever holds it can do anything: install modules, change the root.
  True of every smart account, and no design closes it. Guard that key.

  **Development mode.** A raw call path exists on forks, behind a weaker set of rules, so we
  can test against a real chain. It is refused at boot in production. If it ever reached
  production, the value surface would open.

  **Adapter discipline.** The whole model rests on *every* adapter fixing the target and the
  recipient itself. One adapter that took a destination from the instruction would be exactly
  the redirectable module that drained the Safe. We hold every adapter to the same checklist,
  and prove each one with a test that points the recipient at an attacker and watches the
  assertion fail.
</Callout>

## The part that changed our own confidence

We set out to prove the guard blocks this. Writing that proof turned up that the tests we
already had did not prove it. They reverted a layer too early, in account plumbing, and
passed because the test framework matched the wrong revert loosely. The protection was real,
but the evidence for it was theatre.

<Quote>
  A security test that cannot fail when you break the thing it guards is decoration, and it
  is more dangerous than no test, because it reads as coverage.
</Quote>

So we rebuilt the proof against the guard directly, asserting the exact reason each call is
refused, then broke each control on purpose to watch the matching test fail. Removing the
self-reference line flips its test. Neutering the revoke path flips another. Now we know the
boundary holds instead of assuming it.

## How to evaluate any claim like this

You do not need to trust this page. To tell a real boundary from a filter, ask one
question: **what can the agent, or a passing keeper, write down?**

If the answer includes a destination (a pool, a contract, an address the caller gets to
choose), then every safeguard around it is a check standing between a stranger's chosen
target and your money, and checks are judged against the attacks somebody already imagined.
The September drain is what one caller-chosen target costs.

If the answer is *typed parameters and nothing else*, the attack in this post has nowhere to
land. It needs a target the caller gets to name, and the agent is never given one.

---

*Sources: PeckShieldAlert and Blockaid monitoring reports (15 September 2026), as covered by
PANews, Odaily, Foresight News and BlockBeats; on-chain transactions via Etherscan. Figures
and mechanism are as reported at the time and may be revised as analysis continues.*