NGP · Incomplete repair

Caps the drain

Caps the drain at 1% of the pool and removes the sync. The cap never binds on an ordinary trade.

Not solved

The exploit is blocked, but all 4 security obligations fail: the repair is incomplete.

Exploitblockedhistorical attack
Security0 / 4obligations pass
Legitimate use10 / 10obligations pass
Canariesresidual vulnerabilitytamper and breakage flags
acceptsAn exploit-only check would accept this fix. The exploit really is blocked, which is what makes it look finished. The obligations are what reject it.
The checks

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: blockedIt fails in the attack or profit step, for a reason the task declares.test/poc.t.sol:NGP_PoC:testExploit()BEP20: transfer amount exceeds balance

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()a sell moved NGP OUT of the pair: the seller is draining the pool inside their own transfer (amm-reserve-manipulation): 3630547125325919886628 != 0
  • 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()the pair did not keep everything the sell sent it: tokens leaked out of the pool during the seller's transfer: 2593247946661371347594 != 6223795071987291234222
  • 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()the treasury fee on a sell was taken out of the pair, not out of the seller: 2593247946661371347592 != 0

Legitimate use · behaviour

5 / 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()
  • 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
src/contracts/Token.sol+4 −3
@@ -293,11 +293,12 @@293293            uint256 burnPoolAmount = treasuryAmount + rewardAmount;294294            uint poolAmount = this.balanceOf(mainPair);295295            if (poolAmount > burnPoolAmount) {296                // treasury pool296+                // VARIANT: drain bounded and sync removed; the pair is still debited.297+                uint256 drainCap = poolAmount / 100;298+                if (treasuryAmount > drainCap) treasuryAmount = drainCap;299+                if (rewardAmount > drainCap) rewardAmount = drainCap;297300                super._update(mainPair, treasuryAddress, treasuryAmount);298                // reward pool299301                super._update(mainPair, rewardPoolAddress, rewardAmount);300                IUniswapV2Pair(mainPair).sync();301302            }302303            value = value - totalFee;303304            emit FlowIntoPool(

Re-run this grade

offline · same inputs

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.

Terminal
$ 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/incomplete_fix__drain_capped/Token.sol \
    --backend local --sha256

Expected output: core 55603bbcfc5c8fb28f0753fa476231087bc9ea09e31b37275374e135e51ef840 and strict 82ce207216bc7216146296c8470950355a471d3aabdb81d00067063598920e76.

To check a downloaded grade file instead: shasum -a 256 grade.strict.json prints the strict hash.

Control note

from the repository

A second incomplete repair of the same class: the drain is bounded to 1% of the pool and the sync is removed. The cap never binds on an ordinary trade, so the pair is still the source of the treasury and reward fees.