INK Finance’s Workspace Treasury on Polygon was exploited on 2026-05-11 at block 86711192. The attacker used an address-control / authorization design flaw in the workspace payroll claim path: a freshly deployed CREATE2 contract at 0xd7c643517f98f58d3f9ba91de05d4f62620cfd10 was accepted as an eligible claim actor and triggered the treasury’s authorized transfer path. The transaction drained a net 140,180.175562 USDT, approximately 140,180 USD at USDT par, from treasury proxy 0xa184af4b1c01815a4b57422a3419e4fb78a96ee4 to attacker EOA 0x90b147592191388e955401af43842e19faa87ee2. A Balancer V2 flash loan supplied temporary USDT so the attacker contract could pre-fund the treasury and execute the claim atomically; the flash loan was repaid in the same transaction and was not the root cause.

Root Cause

Vulnerable Contract

Primary vulnerable component: INK Finance Workspace/Claim Controller proxy 0xef2c77f3b9b8aaa067239bc6b4588bae26433494, which delegatecalled implementation 0xc04813a6683f803c9cf6c441357a11182b7e1153 during the exploit. The treasury sink was INK Finance Workspace Treasury proxy 0xa184af4b1c01815a4b57422a3419e4fb78a96ee4, which resolved through beacon 0x9a17a918a5ee23f36d0107eab0effd77d14e7fd0 to implementation 0x72225ccbcb4b6530bd5322a62af5d777afc89890. The controller and treasury implementations are recovered skeletons with medium confidence, so the root cause is derived from trace, prestate, and funds-flow evidence; recovered/disassembled code is only supporting context.

Vulnerable Function

claimPayroll(uint256) on 0xef2c77f3b9b8aaa067239bc6b4588bae26433494, selector 0xcbdcb9ac, was called with claimId = 3. The trace shows the proxy resolving implementation 0xc04813a6683f803c9cf6c441357a11182b7e1153 and delegatecalling the same selector. The controller then called treasury transferTo(address,address,uint256,uint256,uint256,bytes), selector 0x8c57691f, on 0xa184af4b1c01815a4b57422a3419e4fb78a96ee4. The recovered skeleton files are 0xc04813a6683f803c9cf6c441357a11182b7e1153/recovered.sol and 0x72225ccbcb4b6530bd5322a62af5d777afc89890/recovered.sol, with supporting control-flow evidence in each address directory’s disasm.txt.

Vulnerable Code

No verified Solidity source is available for the INK controller or treasury implementations. The following pseudocode is reconstructed from trace_callTracer.json, decoded_calls.json, and supporting disassembly around the observed call path; it is not authoritative source code.

// [recovered - approximation]
function claimPayroll(uint256 claimId) external {
    require(claimId == 3); // observed calldata: 0xcbdcb9ac(...0003)

    uint256 ownerCount = workspace.getDutyOwners(
        0x461cab96cf4e8d93f044537dc0accaa1fa44a556bed2df44eb88ea471c2c186f
    );

    for (uint256 i = 0; i < ownerCount; i++) {
        address owner = workspace.getDutyOwnerByIndex(DUTY_KEY, i);
        // The exploit trace proves the function proceeded even though msg.sender
        // was the newly deployed contract 0xd7c64351..., not either returned owner.
    }

    // claim record storage supplies these transfer parameters.
    address recipient = msg.sender; // <-- VULNERABILITY: recipient/claim actor is address-only caller-controlled
    address token = 0xc2132d05d31c914a87c6611c10748aeb04b58e8f;
    uint256 amount = 165_162_829_883; // 165,162.829883 USDT

    // <-- VULNERABILITY: no trace-observable binding between claimId 3 and a stable identity
    // such as an EOA signature, nonce-bound authorization, employee account, or immutable claimant.
    treasury.transferTo(recipient, token, 20, 0, amount, "");
}

// [recovered - approximation]
function transferTo(
    address to,
    address token,
    uint256 opType,
    uint256 id,
    uint256 amount,
    bytes calldata data
) external returns (bool) {
    // msg.sender is the controller proxy 0xef2c77f3..., so treasury authorization succeeds.
    IERC20(token).transfer(to, amount); // <-- VULNERABILITY IMPACT: transfers treasury USDT to attacker-controlled contract

    // Trace shows this post-transfer interface check succeeds on the attacker contract.
    IERC165(to).supportsInterface(0xf3384444);
    return true;
}

The supporting disassembly for the controller path shows claimPayroll building getDutyOwners(bytes32) and getDutyOwnerByIndex(bytes32,uint256) calls with duty key 0x461cab96cf4e8d93f044537dc0accaa1fa44a556bed2df44eb88ea471c2c186f, then building the treasury 0x8c57691f call. The treasury disassembly shows the observed path issuing an ERC-20 transfer(address,uint256) and then a supportsInterface(bytes4) call to the recipient. The exact Solidity-level branch names and storage variable names remain unknown because the recovered source is skeleton-only.

Why It’s Vulnerable

Expected behavior: a payroll claim should bind claimId to a stable, pre-authorized claimant identity and reject a newly deployed arbitrary contract unless that exact contract was explicitly authorized through a safe enrollment flow. If the protocol allows contract recipients, the authorization should still validate a non-spoofable identity or signature before instructing the treasury to transfer funds.

Actual behavior proven by the trace: 0xd7c643517f98f58d3f9ba91de05d4f62620cfd10 was created during the same transaction, then immediately called claimPayroll(3), and the controller still called the treasury with that contract as the recipient. The metadata lookups returned two duty owners, 0xB39195A2BfBe5540c5Ad8d59089eE4443C441659 and 0xb624f0c1Ec439b3B0e41A80B9d1bb1D8d56F9aE8, but the successful caller/recipient was neither of those addresses. The missing check is therefore an authorization binding check between the payroll claim and the actual claimant/caller; address-only or interface-based acceptance let the attacker deploy a contract that satisfied the claim path and receive treasury funds.

Normal flow: an authorized workspace actor claims payroll, the controller verifies the actor against workspace/claim state, and the treasury transfers the configured token amount to the legitimate recipient. Attack flow: the attacker deployed a contract at 0xd7c64351..., borrowed USDT, pre-funded the treasury, called claimPayroll(3), and the controller forwarded recipient = 0xd7c64351... plus amount = 165,162.829883 USDT to the treasury without a trace-visible rejection. The temporary pre-funding made the treasury balance sufficient, but the loss was the net difference between the larger treasury payout and the temporary deposit.

Attack Execution

High-Level Flow

  1. The attacker EOA deployed an orchestrator contract, which deployed the final claimer/flash-loan receiver contract with CREATE2.
  2. The orchestrator requested a Balancer V2 flash loan of 24,982.654321 USDT to the claimer contract.
  3. Inside the flash-loan callback, the claimer pre-funded the INK treasury with the borrowed USDT.
  4. The claimer called INK’s workspace controller to execute payroll claim 3.
  5. The controller accepted the new claimer contract as the claim recipient and instructed the treasury to transfer 165,162.829883 USDT to it.
  6. The claimer repaid the Balancer loan amount and sent the remaining 140,180.175562 USDT to the attacker EOA.

Detailed Call Trace

  • Depth 0: 0x90b147592191388e955401af43842e19faa87ee2 -> creates 0x74f28b9a35d72504e007c60803ef47f1a44b109e (CREATE, constructor input begins 0x60808060).
  • Depth 1: 0x74f28b9a35d72504e007c60803ef47f1a44b109e -> creates 0xd7c643517f98f58d3f9ba91de05d4f62620cfd10 (CREATE2, init input begins 0x61014034).
  • Depth 1: 0x74f28b9a35d72504e007c60803ef47f1a44b109e -> 0xba12222222228d8ba445958a75a0704d566bf2c8 flashLoan(address,address[],uint256[],bytes) (0x5c38449e), requesting USDT 0xc2132d05d31c914a87c6611c10748aeb04b58e8f amount 24,982.654321.
    • Depth 2: Balancer Vault -> USDT balanceOf(address) (0x70a08231) for the vault, via USDT implementation delegatecall.
    • Depth 2: Balancer Vault -> 0xce88686553686da562ce7cea497ce749da109f9f getFlashLoanFeePercentage() (0xd877845c), returning zero fee.
    • Depth 2: Balancer Vault -> USDT transfer(address,uint256) (0xa9059cbb) to 0xd7c643517f98f58d3f9ba91de05d4f62620cfd10, amount 24,982.654321.
    • Depth 2: Balancer Vault -> 0xd7c643517f98f58d3f9ba91de05d4f62620cfd10 receiveFlashLoan(address[],uint256[],uint256[],bytes) (0xf04f2707).
      • Depth 3: Claimer -> USDT transfer(address,uint256) to treasury 0xa184af4b1c01815a4b57422a3419e4fb78a96ee4, amount 24,982.654321.
      • Depth 3: Claimer -> controller proxy 0xef2c77f3b9b8aaa067239bc6b4588bae26433494 claimPayroll(uint256) (0xcbdcb9ac) with claimId = 3.
        • Depth 4: Controller proxy -> beacon 0xfc6f5c4d4bcea3135797c2f0437903192e5a8508 implementation() (0x5c60da1b), returning 0xc04813a6683f803c9cf6c441357a11182b7e1153.
        • Depth 4: Controller proxy -> controller implementation 0xc04813a6683f803c9cf6c441357a11182b7e1153 (DELEGATECALL) claimPayroll(uint256) (0xcbdcb9ac).
          • Depth 5: Controller -> workspace metadata proxy 0xbc90580ec58e52225c4dd856711b6d79d3471c82 getDutyOwners(bytes32) (0x21bd3834) for duty key 0x461cab96cf4e8d93f044537dc0accaa1fa44a556bed2df44eb88ea471c2c186f, returning 2.
          • Depth 5: Controller -> workspace metadata proxy getDutyOwnerByIndex(bytes32,uint256) (0xff67cb0c) index 0, returning 0xB39195A2BfBe5540c5Ad8d59089eE4443C441659.
          • Depth 5: Controller -> workspace metadata proxy getDutyOwnerByIndex(bytes32,uint256) (0xff67cb0c) index 1, returning 0xb624f0c1Ec439b3B0e41A80B9d1bb1D8d56F9aE8.
          • Depth 5: Controller -> treasury proxy 0xa184af4b1c01815a4b57422a3419e4fb78a96ee4 transferTo(address,address,uint256,uint256,uint256,bytes) (0x8c57691f) with recipient 0xd7c643517f98f58d3f9ba91de05d4f62620cfd10, token USDT, type/id words 20 and 0, amount 165,162.829883, empty bytes.
            • Depth 6: Treasury proxy -> beacon 0x9a17a918a5ee23f36d0107eab0effd77d14e7fd0 implementation() (0x5c60da1b), returning 0x72225ccbcb4b6530bd5322a62af5d777afc89890.
            • Depth 6: Treasury proxy -> treasury implementation 0x72225ccbcb4b6530bd5322a62af5d777afc89890 (DELEGATECALL) transferTo(address,address,uint256,uint256,uint256,bytes) (0x8c57691f).
              • Depth 7: Treasury -> USDT transfer(address,uint256) to 0xd7c643517f98f58d3f9ba91de05d4f62620cfd10, amount 165,162.829883.
              • Depth 7: Treasury -> claimer supportsInterface(bytes4) (0x01ffc9a7) with interface id 0xf3384444, returning true.
      • Depth 3: Claimer -> USDT transfer(address,uint256) to Balancer Vault, amount 24,982.654321, repaying the flash loan.
      • Depth 3: Claimer -> USDT transfer(address,uint256) to attacker EOA 0x90b147592191388e955401af43842e19faa87ee2, amount 140,180.175562.
    • Depth 2: Balancer Vault -> USDT balanceOf(address) for the vault, confirming repayment.

All function selectors above were verified locally with cast sig; for example cast sig "claimPayroll(uint256)" returns 0xcbdcb9ac and cast sig "transferTo(address,address,uint256,uint256,uint256,bytes)" returns 0x8c57691f.

Financial Impact

funds_flow.json shows the attacker EOA gained 140,180.175562 USDT (0xc2132d05d31c914a87c6611c10748aeb04b58e8f, 6 decimals), approximately 140,180 USD. The victim treasury’s net USDT change was -140,180.175562: it received the flash-loaned 24,982.654321 USDT from the claimer, then paid 165,162.829883 USDT back to the same claimer. Balancer’s net USDT change was zero because 24,982.654321 USDT was borrowed and repaid with zero fee.

The immediate loser was the INK Finance workspace treasury/protocol funds held at 0xa184af4b1c01815a4b57422a3419e4fb78a96ee4. A historical balance check at block 86711191 shows the treasury held exactly 140,180.175562 USDT before the transaction and 0 after block 86711192, consistent with a full drain of the treasury’s pre-existing USDT balance. Gas costs were paid separately in MATIC by the attacker EOA; no ETH/MATIC value transfers are part of the token loss accounting.

Evidence

  • Transaction status: receipt.json.status is 0x1, confirming the exploit transaction succeeded.
  • Primary fund-flow events: USDT Transfer logs at indices 1971 and 1972 show the claimer pre-funding the treasury with 24,982.654321 USDT and the treasury sending 165,162.829883 USDT back to the claimer; log 1976 shows 140,180.175562 USDT sent to the attacker EOA.
  • Claim event: receipt log 1974 from controller 0xef2c77f3b9b8aaa067239bc6b4588bae26433494 indexes claimId = 3 and claimant/recipient 0xd7c643517f98f58d3f9ba91de05d4f62620cfd10, with data including USDT and amount 0x267478c83b (165,162.829883).
  • Metadata lookups: the trace shows duty key 0x461cab96cf4e8d93f044537dc0accaa1fa44a556bed2df44eb88ea471c2c186f had two owners and returned 0xB39195A2BfBe5540c5Ad8d59089eE4443C441659 and 0xb624f0c1Ec439b3B0e41A80B9d1bb1D8d56F9aE8, while the successful caller/recipient was the newly created 0xd7c643517f98f58d3f9ba91de05d4f62620cfd10.
  • Prestate diff: trace_prestateTracer.json records the new claimer contract code being created in the transaction and a controller storage slot changing to 1, consistent with claim consumption.