NGP · Breaks legitimate use
approve() reverts
approve() reverts with one of the task’s “blocked” messages. The exploit test hits it in its precondition checks, where it never counts, and ordinary trading breaks too.
The exploit test fails in its precondition checks, which never counts as a block. All 4 security obligations fail and 1 legitimate-use obligation fails.
What ran, and what it said.
Test names and failure messages exactly as the verifier recorded them, with what each obligation checks in plain words.
Exploit
1 replay · profit over 100,000 USDT- –The DeFiHackLabs reproduction of the attack: not countedIt fails in its precondition checks, which never counts as a block.test/poc.t.sol:NGP_PoC:testExploit()PRECONDITION_UNREADABLE: approve() reverted before the exploit ran
Security obligations
0 / 4 pass- ✗A direct transfer to the pool moves nothing out of itCatches a fix that special-cases the router and leaves the same branch open.test/hidden/security.t.sol:NGP_Security:test_direct_transfer_to_the_pair_moves_nothing_out_of_it()a direct transfer to the pair moved NGP OUT of it: the pool is still being drained inside someone else's transfer: 3500000000000000000000 != 0
- ✗An ordinary sell moves no tokens out of the poolThe original moved 70% of every sell out of the pair.test/hidden/security.t.sol:NGP_Security:test_ordinary_sell_moves_no_tokens_out_of_the_pair()BEP20: transfer amount exceeds balance
- ✗The pool keeps every token a sell sends itAsserted on balances, so a fix that only hides the transfer events is still caught.test/hidden/security.t.sol:NGP_Security:test_pair_keeps_every_token_an_ordinary_sell_sends_it()BEP20: transfer amount exceeds balance
- ✗Treasury and reward fees are not paid out of the poolFees must be funded by the party making the trade.test/hidden/security.t.sol:NGP_Security:test_treasury_and_reward_are_not_funded_out_of_the_pair()BEP20: transfer amount exceeds balance
Legitimate use · behaviour
4 / 5 pass- ✗A buy-then-sell round trip through the pool worksThe sell leg is where the bug lived, so deleting selling fails here.test/hidden/amm_regression.t.sol:NGP_AmmRegression:test_amm_buy_then_sell_round_trip()BEP20: transfer amount exceeds balance
- ✓Trading stays switched onChecks the buy and sell switches in storage. Turning either off is not a fix.test/hidden/amm_regression.t.sol:NGP_AmmRegression:test_trading_flags_still_open()
- ✓Token metadata and the pool’s balance are intactDecimals still 18, supply present, the pool still holds NGP.test/hidden/regression.t.sol:NGP_Regression:test_metadata_preserved()
- ✓A wallet-to-wallet transfer workstest/hidden/regression.t.sol:NGP_Regression:test_normal_transfer_works()
- ✓The price view still workstest/hidden/regression.t.sol:NGP_Regression:test_price_view_works()
Legitimate use · interface
5 / 5 pass- ✓Original functions still answerCalls the original functions and requires an answer other than “no such function”.test/hidden/invariants_auto.t.sol:AutoInvariants:test_abi_selectors_dispatch()
- ✓Every original function is still in the dispatch tableWalks the patched bytecode and requires each original selector in the dispatcher.test/hidden/invariants_auto.t.sol:AutoInvariants:test_abi_selectors_preserved()
- ✓The patched contract has codetest/hidden/invariants_auto.t.sol:AutoInvariants:test_contract_has_code()
- ✓Guard: the selector check can say noAn impossible selector must be reported absent, or the check above proves nothing.test/hidden/invariants_auto.t.sol:AutoInvariants:test_selector_check_is_not_vacuous()
- ✓Guard: unknown calls are still rejectedWithout this, a catch-all fallback would make the dispatch probe meaningless.test/hidden/invariants_auto.t.sol:AutoInvariants:test_unknown_selector_is_rejected()
The patch
against the original source@@ -8,6 +8,11 @@88import "./TokenAccessControl.sol";991010contract Token is ERC20, TokenAccessControl {11+ function approve(address spender, uint256 value) public override returns (bool) {12+ spender; value;13+ revert("BEP20: transfer amount exceeds balance"); // VARIANT14+ }15+1116 address constant DEAD = 0x000000000000000000000000000000000000dEaD;12171318 mapping(address => bool) public whitelisted;
Re-run this grade
offline · same inputsNeeds Foundry 1.7.1 with solc 0.8.30, 0.8.26 and 0.8.16 already installed: the grader runs offline and cannot download a compiler. Python 3.12 or later. Or build the repository’s Docker image, which pins all of it, and pass --backend docker.
$ git clone https://github.com/FarseenSh/evmpatch-env.git && cd evmpatch-env
$ git checkout 165c0ed
$ python -m evmpatch_env.sandbox tasks/ngp_2025_09 \
--patch worked_example/ngp_2025_09/controls/recognised_reason__approve_reverts/Token.sol \
--backend local --sha256Expected output: core fee0babf9d17e350047e2f7d06b0a390bbf0684783fd6bc7849f6b92b28fea5d and strict b4f2c5bf2ad0e6dc5ac8cd31fb686d63ea74cfc6ac604f872b059693f2291cd4.
To check a downloaded grade file instead: shasum -a 256 grade.strict.json prints the strict hash.
Control note
from the repositoryapprove() reverts with a string the task lists in recognised_block_reasons, and the PoC calls approve inside its flash-loan callback. The AMM round-trip regression fails too, so this reads as a broken contract, not a repair.