NGP
Every sell pulled fees out of the pool and re-priced it mid-trade.
On every sell, NGP’s transfer logic moved the treasury and reward fees, 70% of the sell, out of the liquidity pair, then called sync() inside the seller’s own transfer, so the seller re-priced the pool’s reserves in the middle of the trade.
The exploit used a flash loan and two router swaps to profit from that re-pricing.
Remove the mid-transfer drain of the pair and the sync. The seller-side market and burn fees stay.
The exploit needs both the drain and the sync, so deleting only the sync stops it. The security obligations state that no tokens may leave the pool during a sell, which is what catches that fix.
- ↗Original proof of conceptDeFiHackLabs · src/test/2025-09/NGP_exp.sol · the source of the loss figure
- ↗Attack transactionthe block after the fork
- ↗Exploited contract0xd2F26200cD524dB097Cf4ab7cC2E5C38aB6ae5c9
- ↗The task in evmpatch-envfrozen state, locked harness, hidden obligations
The exploit, plus 14 hidden obligations.
The exploit must fail for a reason the task declares. The security obligations state the vulnerability class, so each one fails on the original contract by construction. The legitimate-use obligations hold before and after a correct repair. Hidden means kept out of the fixer’s workspace; every test is published in the repository.
The exploit
1 replay- !The DeFiHackLabs reproduction of the attack, replayed at block 61,515,894 It lands if the attacker’s profit clears 100,000 USDT (the incident: about 2M USDT lost). It counts as blocked only if it fails in the attack or profit step with an assertion failure, “exploit did not yield profit” or “BEP20: transfer amount exceeds balance”. test/poc.t.sol:NGP_PoC:testExploit()
Security obligations
4 · fail on the original- !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()
- !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()
- !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()
- !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()
Legitimate use · behaviour
5 · hand-written- ✓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()
- ✓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 · generated- ✓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()
10 fixes, graded.
Complete repairs, near-misses, fixes that break the contract, fixes that only look like repairs, the original contract as a baseline and runs with missing evidence, each written to probe the verifier. 2 of them stop the exploit and are still not repairs. An exploit-only check would accept both; they are highlighted.
| Fix | Kind | Exploit | Security | Legit use | SCAR verdict |
|---|---|---|---|---|---|
| Reference repairRemoves the mid-transfer drain and sync, and keeps the seller-side market and burn fees. | Complete repair | blocked | 4 / 4 | 10 / 10 | Solved |
| Alternative repair: the seller pays the feesCharges the treasury and reward fees to the seller instead of pulling them from the pool. | Complete repair | blocked | 4 / 4 | 10 / 10 | Solved |
| Caps the drainCaps the drain at 1% of the pool and removes the sync. The cap never binds on an ordinary trade. | Incomplete repair | blocked | 0 / 4 | 10 / 10 | Not solved |
| Removes the sync, keeps the drainDeletes only the mid-transfer sync. The pool is still drained inside the seller’s own transfer. | Incomplete repair | blocked | 0 / 4 | 10 / 10 | Not solved |
| Repair that closes sellingThe reference repair plus every sell reverting. The market is closed, not fixed. | Breaks legitimate use | not counted | 0 / 4 | 9 / 10 | Not solved |
| approve() revertsapprove() 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. | Breaks legitimate use | not counted | 0 / 4 | 9 / 10 | Not solved |
| Cosmetic changeA rename with no effect on behaviour. The exploit still lands. | Non-repair | lands | 0 / 4 | 10 / 10 | Not solved |
| Empty patchA “patch” byte-identical to the original source. | Baseline | lands | 0 / 4 | 10 / 10 | Not solved |
| Original contractThe code as it was exploited. The attack still works. | Baseline | lands | 0 / 4 | 10 / 10 | Not solved |
| Syntax errorThe reference repair with a syntax error injected. | Missing evidence | not run | — | — | Inconclusive |
Every row opens its receipt. Exploit: blocked; lands; not counted (the exploit test fails, but in its precondition checks or for a reason the task does not declare); not run; or not established on missing state.
Every grade here can be re-run.
The task ships its frozen chain state: 139 recorded RPC responses, served offline by a proxy that refuses anything it did not record. On 23 Sep 2026 all 10 grades in this case reproduced on two machines, both hashes each: a fresh clone on the Mac that recorded them, and GitHub’s Linux CI runner.
Needs 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 # the reference repair $ python -m evmpatch_env.sandbox tasks/ngp_2025_09 --reference-patch --backend local --sha256 # the original contract $ python -m evmpatch_env.sandbox tasks/ngp_2025_09 --backend local --sha256 # every test fix in this case (needs uv) $ uv run --no-project --python 3.12 python worked_example/ngp_2025_09/controls/run_controls.py
Reference: core cb637d04…5b94, receipt. Original: core 25b53d32…77df, receipt.