Skip to content

security: DinEmission.fundGI lets anyone mint and drain DIN via an unvalidated taskAuditor address #226

Closed

Description

@Abidoyesimze

Summary

DinEmission.fundGI(uint256 gi, address taskAuditor) is external, callable by anyone, and never validates that taskAuditor is a real, registry-registered task-auditor contract. Since taskAuditor is both the recipient of a fresh ERC-20 allowance over newly-minted DIN and the target of an external call, an attacker can supply their own contract address and drain the mint in a single transaction — repeatably, bypassing the emission decay schedule entirely.

Where

foundry/src/DinEmission.sol (verified against develop @ 740a613), fundGI (~L174-201):

function fundGI(uint256 gi, address taskAuditor) external nonReentrant returns (uint256 amount) {
    if (taskAuditor == address(0)) revert InvalidAddress();
    if (giEmissionFunded[taskAuditor][gi]) revert GIAlreadyFunded(gi);

    EmissionState storage state = emissionState[taskAuditor];
    if (!state.started) { state.currentEmissionPerGI = initialEmissionPerGI; state.started = true; }
    if (state.currentEpoch >= maxEpochs) revert EmissionExhausted();

    amount = state.currentEmissionPerGI;
    ...
    coordinator.mintEmission(address(this), amount);          // mints to DinEmission itself
    dinToken.safeIncreaseAllowance(taskAuditor, amount);       // approves arbitrary `taskAuditor`
    IDINTaskAuditor(taskAuditor).depositRewards(gi, amount);   // external call to arbitrary address
    ...
}

Neither DinCoordinator.mintEmission (only checks the caller is DinEmission itself) nor the real DINTaskAuditor.depositRewards (just does dinToken.safeTransferFrom(msg.sender, address(this), amount), trusting whoever calls it) constrains taskAuditor. emissionState/giEmissionFunded are keyed by this same attacker-chosen address, so a fresh fake contract each time also resets the decay schedule — there's no protection beyond DinCoordinator.mintCap, which defaults to 0 (uncapped, confirmed in DinCoordinator.initialize()).

The function's own doc comment rationalizes the open caller ("the amount is deterministic from the epoch schedule so there is no benefit to restricting the caller") — but that reasoning only covers the amount, not the recipient. It misses that taskAuditor is the allowance grantee and call target, which is the actual exploitable surface. Reads as a genuine oversight, not an accepted risk — no mention of this in Developer/BACK_LOG.md or Documentation/technical/audits/foundry-src-security-review.md.

Exploit

  1. Attacker deploys a trivial contract whose depositRewards(uint256, uint256 amount) just does dinToken.transferFrom(msg.sender, attacker, amount).
  2. Attacker calls DinEmission.fundGI(gi, address(thatContract)).
  3. In one transaction: DinEmission mints amount to itself, approves the attacker's contract for amount, then calls into it — which immediately pulls the full amount via the allowance.
  4. Repeatable indefinitely with fresh fake contracts, each getting the full undecayed initialEmissionPerGI (the decay schedule is keyed per-taskAuditor, so a new address always starts fresh).

Verified with a from-scratch Foundry PoC against develop @ 740a613 (not reusing any other draft): test_fundGI_arbitraryTaskAuditor_drainsMintedDIN (one-call full drain — attacker balance +amount, DinEmission balance returns to 0, totalMinted genuinely inflated) and test_fundGI_repeatedWithFreshFakeAuditors_eachGetsUndecayedAmount (3 fresh fake contracts each independently get the full undecayed amount) both pass. The existing EmissionTests.t.sol suite (21 tests) also still passes unmodified — nothing in it exercises an unregistered taskAuditor, confirming this isn't an already-tested/accepted boundary.

Impact

Unbounded DIN token theft and inflation, gated only by attacker gas cost — mintCap is 0 (uncapped) by default on every network including the current devnet.

Recommendation

Validate taskAuditor before minting/approving — e.g. DINModelRegistry.getModelIdByTaskAuditor(taskAuditor) != 0 or DinValidatorStake.isSlasherContract(taskAuditor), mirroring the check DINModelRegistry.requestModelRegistration already does for coordinator/auditor pairs elsewhere in the codebase.

Activity

  1. umeradl commented on Oct 6, 2026

    @umeradl
    Member

    Status update: confirmed; scheduled as task_061026_22 Part A

    Checked against develop @ a1fcce2. The finding holds. DinEmission.fundGI (foundry/src/DinEmission.sol:174) is external and permissionless, and only rejects address(0). It then mints to itself (coordinator.mintEmission), grants taskAuditor an allowance, and calls IDINTaskAuditor(taskAuditor).depositRewards, all on an address the caller chooses. emissionState and giEmissionFunded are keyed by that same address, so each fresh fake contract starts the schedule over at initialEmissionPerGI. DinCoordinator.mintCap defaults to 0 (uncapped). This is a mint-and-drain with no cost beyond gas.

    Can be done now: yes. It goes in task_061026_22 as Part A, assigned to you. Notes for the fix:

    • Recommended check shape. DINModelRegistry.getModelIdByTaskAuditor(taskAuditor) returns (bool exists, uint256 modelId), not an id where 0 means "none". Model 0 is a real model ID (modelId = models.length), so the check has to use exists, not modelId != 0. Only auditors of approved models should be fundable. Whether a disabled model (modelDisabled[modelId]) is also refused is a decision to flag in the PR.
    • DinEmission has no registry reference today. It holds coordinator and dinToken. Adding one is a new storage variable taken from __gap (:115; shrink the gap by one), set in initialize and/or by an owner setter, and wired in foundry/script/DeployPlatform.s.sol. UpgradeValidation.t.sol and the storage-layout doc must stay consistent.
    • Why not DinValidatorStake.isSlasherContract: slasher status is granted before registry approval, and covers coordinators as well as auditors. The registry's auditor mapping is the tighter allow-list.
    • Tests: your two PoC tests (test_fundGI_arbitraryTaskAuditor_drainsMintedDIN, test_fundGI_repeatedWithFreshFakeAuditors_eachGetsUndecayedAmount) become regression tests that now expect a revert. The existing EmissionTests.t.sol fixtures need a registered auditor.

    Coordination: task-plan-051026-2 (PR No. 229) TP-3 adds a dincli model-owner rewards fund-emission command on top of fundGI, and has to follow whatever check lands here. Full spec: task_061026_22 (linked from the tracking discussion once it's open).

  2. umeradl commented on Oct 7, 2026

    @umeradl
    Member

    Done by PR No. 234, merged into develop as 2fa8ce8, with a review follow-up in 07fd597. The develop push CI for 07fd597 is green. Closing by hand, because a merge into develop doesn't trigger closing keywords (main is the default branch).

    Checked on origin/develop @ 07fd597:

    • Registry check (the fix): fundGI keeps the zero-address check first. Then it reads (bool exists, uint256 modelId) = modelRegistry.getModelIdByTaskAuditor(taskAuditor) and reverts UnregisteredTaskAuditor if !exists. That happens before any state write, mint or allowance (foundry/src/DinEmission.sol:213-217). It checks exists, not modelId != 0, so model 0 is handled. The registry fills _modelIdByTaskAuditor only in approveModel (foundry/src/DINModelRegistry.sol:251), so only auditors of approved models can be funded.
    • Disabled-model decision: refused. fundGI reverts ModelDisabledForEmission(modelId) when modelRegistry.modelDisabled(modelId) is true (DinEmission.sol:216). The rationale is recorded in Documentation/technical/audits/foundry-src-security-review.md.
    • Registry reference: IDINModelRegistry public modelRegistry (DinEmission.sol:129). It is set in initialize, which reverts InvalidAddress on zero (:158-164), and by an onlyOwner setModelRegistry that rejects zero and emits ModelRegistryUpdated (:175-178). DeployPlatform.s.sol:285-291 passes dinModelRegistryProxy to initialize. storage_layout.md and DeployPlatform.md step 13 match.
    • Tests: the two PoC tests are now regression tests that expect a revert: test_fundGI_unregisteredTaskAuditor_revertsBeforeAnyStateChange and test_fundGI_repeatedFreshUnregisteredAuditors_allRevert. There is also test_fundGI_disabledModel_reverts, plus three setModelRegistry tests (foundry/test/EmissionTests.t.sol:200-270). UpgradeValidation.t.sol still validates DinEmission. The full suite is green in the CI run above.
    • Deviation: the approval comment and task_061026_22 A1 said to shrink __gap from 50 to 49. 07fd597 restored uint256[50], because nothing on develop is deployed yet. Per the "Core rules" in storage_layout.md, new variables go above __gap without shrinking it. modelRegistry stays at slot 9, and the A1 amendment in task_061026_22 records the change.

    Follow-ups outside this issue:

    • fundGI is still permissionless for registered auditors, so anyone can use up a model's schedule by funding far-future GIs. This was found in the PR No. 234 review and is tracked in issue No. 238.
    • The dincli model-owner rewards fund-emission command, which has to follow this check, is task-plan-051026-2 TP-3, forwarded as task_061026_21.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions