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.
