Due Dilligence: BIP-442 Karpatkey Payload
Tenderly Simulation
Interpreting Events
The payload can in this case be intepreted through the events it emits on a successful (simulated) execution. This is possible because the only functions that are called are approvals on ERC20s or scope changes on the Roles
module. All these functions emit relevant and exhaustive events.
Approval
Approval
events are pretty straight forward to interpret:
{
"owner": "0x0efccbb9e2c09ea29551879bd9da32362b32fc89"
"spender": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0"
"value": "115792089237316195423570985008687907853269984665640564039457584007913129639935"
}
The event is emitted from 0xae7ab96520de3a18e5e111b5eaab095312d7fe84
, which is stETH. On it, the managed safe (0x0efcc) approves the spender (wstETH) to spend infinite amount of tokens.
In all cases here, the owner is the managed safe and the amount of tokens is infinite. Mapping of tokens and spenders:
token (symbol) | spender (label) |
---|---|
0xae7ab96520de3a18e5e111b5eaab095312d7fe84 (stETH) |
0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0 (wstETH) |
0xae7ab96520de3a18e5e111b5eaab095312d7fe84 (stETH) |
0x889edc2edab5f40e902b864ad4d7ade8e412f9b1 (unstETH) |
0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0 (wstETH) |
0x889edc2edab5f40e902b864ad4d7ade8e412f9b1 (unstETH) |
0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 (USDC) |
0xc3d688b66703497daa19211eedff47f25384cdc3 (cUSDCv3) |
0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 (USDC) |
0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2 (Aave: Pool V3) |
0x6b175474e89094c44da98b954eedeac495271d0f (DAI) |
0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2 (Aave: Pool V3) |
0xae78736cd615f374d3085123a210448e74fc6393 (rETH) |
0x16d5a408e807db8ef7c578279beeee6b228f1c1c (RocketSwapRouter) |
0x6b175474e89094c44da98b954eedeac495271d0f (DAI) |
0x373238337bfe1146fb49989fc222523f83081ddb (DsrManager) |
0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9 (AAVE) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
0xc00e94cb662c3520282e6f5717214004a7f26888 (COMP) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
0x6b175474e89094c44da98b954eedeac495271d0f (DAI) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
0xae78736cd615f374d3085123a210448e74fc6393 (RETH) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
0x20bc832ca081b91433ff6c17f85701b6e92486c5 (rETH2) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
0xfe2e637202056d30016725477c5da089ab0a043a (sETH2) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
0x889edc2edab5f40e902b864ad4d7ade8e412f9b1 (unstETH) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
0x48e5413b73add2434e47504e2a22d14940dbfe78 (INRM) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 (USDC) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
0xdac17f958d2ee523a2206206994597c13d831ec7 (USDT) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (WETH) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
0x2260fac5e5542a773aa44fbcfedf7c193bc2c599 (WBTC) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0 (wstETH) |
0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 (GPv2VaultRelayer; CoW Swap) |
WARNING: the approval of 0x48e5413b73add2434e47504e2a22d14940dbfe78
(INRM) seems unnecessary and is a bit puzzling to me. It is for example not whitelisted for the CowswapOrderSigner.signOrder.sellToken
parameter (see ScopeParameterAsOneOf
section). Is there something I am missing here, or is this a bug? How did this token get into this list?
ScopeFunction
Besides the (more readable) approvals, this payload makes changes to the Roles
module, which is currently enabled on the managed safe.
We can verify these permission changes made by checking the events emitted in the Tenderly simulation, e.g.:
{
"role": 1
"targetAddress": "0x889edc2edab5f40e902b864ad4d7ade8e412f9b1"
"functionSig": "0xd6681042"
"isParamScoped": [
0: false
1: true
]
"paramType": "AAA="
"paramComp": "AAA="
"compValue": [
0: "0x"
1: "0x0000000000000000000000000efccbb9e2c09ea29551879bd9da32362b32fc89"
]
"options": 0
"resultingScopeConfig": "3533694129556781213370065774847364703233814209084070120954101330654265344"
}
role
here is the index of the Role
struct, meaning in this case that there is a target type role change:
struct Role {
mapping(address => bool) members;
mapping(address => TargetAddress) targets;
mapping(bytes32 => uint256) functions;
mapping(bytes32 => bytes32) compValues;
mapping(bytes32 => bytes32[]) compValuesOneOf;
}
target
and functionSig
translate to the following:
>>> target = Contract('0x889edc2edab5f40e902b864ad4d7ade8e412f9b1')
>>> target.name()
'Lido: stETH Withdrawal NFT'
>>> target.selectors['0xd6681042']
'requestWithdrawals'
This corresponds to point (5.) from karpatkey’s specs:
- Unstake stETH on Lido;
The function’s full signature is requestWithdrawals(uint256[] _amounts, address _owner)
, therefore:
- setting
isParamScoped
for the parameter with index0
tofalse
means any amount could be withdrawm, i.e. no limitation isParamScoped
for the parameter for index1
is set totrue
with value0x0000000000000000000000000efccbb9e2c09ea29551879bd9da32362b32fc89
; the withdrawal can only take place to0x0EFcCBb9E2C09Ea29551879bd9Da32362b32fc89
which is the managed treasury multisig address
options
can give permission to send ether, use delegate_call
or both:
enum ExecutionOptions {
None,
Send,
DelegateCall,
Both
}
We can repeat this for all other scope type events emitted:
contract | signature | scope |
---|---|---|
"Lido: stETH Withdrawal NFT" |
requestWithdrawals(uint256[] _amounts, address _owner) |
ANY, 0x0efcc |
"Lido: stETH Withdrawal NFT" |
requestWithdrawalsWstETH(uint256[] _amounts, address _owner) |
ANY, 0x0efcc |
"Aave: Pool V3" |
supply(address asset, uint256 amount, address onBehalfOf) |
SCOPED*, ANY, 0x0efcc |
"Aave: Pool V3" |
withdraw(address asset, uint256 amount, address onBehalfOf) |
SCOPED*, ANY, 0x0efcc |
"Compound USDC (cUSDCv3)" |
supply(address asset, uint256 amount) |
USDC, ANY |
"Compound USDC (cUSDCv3)" |
withdraw(address asset, uint256 amount) |
USDC, ANY |
"CometRewards" |
claim(address comet, address src, bool shouldAccrue) |
cUSDCv3, 0x0efcc, ANY |
"DsrManager" |
join(address dst, uint256 wad) |
0x0efcc, ANY |
"DsrManager" |
exitAll(address dst) |
0x0efcc |
"RocketDepositPool" |
deposit() |
SEND_ETHER |
"Rocket Pool ETH" |
burn(uint256 _rethAmount) |
ANY |
"RocketSwapRouter" |
swapTo(uint256 _uniswapPortion, uint256 _balancerPortion, uint256 _minTokensOut, uint256 _idealTokensOut) |
ANY*, SEND_ETHER |
"RocketSwapRouter" |
swapFrom(uint256 _uniswapPortion, uint256 _balancerPortion, uint256 _minTokensOut, uint256 _idealTokensOut, uint256 _tokensIn) |
ANY* |
"CowswapOrderSigner" |
signOrder(address sellToken, address buyToken, uint256 sellAmount, uint256 buyAmount, uint32 validTo, uint32 validDuration, uint256 feeAmount, uint256 feeAmountBP, bytes32 kind, bool partiallyFillable, bytes32 sellTokenBalance, bytes32 buyTokenBalance) |
SCOPED*, SCOPED*, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, SEND_ETHER, DELEGATE_CALL |
WARNING: deployer of CowswapOrderSigner
does not look to be CoW DAO. Who deployed this contract?
WARNING: the feeAmountBP
is not scoped, and can therefore be set to something like 99%. There is a potential for malicious/griefing transactions here!
ScopeParameterAsOneOf
Some parameters do not get limited to a single value but to a list of values, as can be read from the ScopeParameterAsOneOf
event:
ScopeParameterAsOneOf{
"role": 1
"targetAddress": "0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2"
"functionSig": "0x617ba037"
"index": "0"
"paramType": 0
"compValues": [
0: "0x0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f"
1: "0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
]
"resultingScopeConfig":"5300541194335184374258569435632574726429567728958937386142040924050423811"
}
So the parameter with index 0
("Aave: Pool V3".supply.asset
) can have two values:
0x6b175474e89094c44da98b954eedeac495271d0f
(DAI)0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
(USDC)
"Aave: Pool V3".withdraw.asset
can be set to the same two addresses:
0x6b175474e89094c44da98b954eedeac495271d0f
(DAI)0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
(USDC)
The CowswapOrderSigner.signOrder.sellToken
parameter gets scoped to the following whitelist:
0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9
(AAVE)0xc00e94cb662c3520282e6f5717214004a7f26888
(COMP)0x6b175474e89094c44da98b954eedeac495271d0f
(DAI)0xae78736cd615f374d3085123a210448e74fc6393
(rETH)0x20bc832ca081b91433ff6c17f85701b6e92486c5
(rETH2)0xfe2e637202056d30016725477c5da089ab0a043a
(sETH2)0xae7ab96520de3a18e5e111b5eaab095312d7fe84
(stETH)0x48c3399719b582dd63eb5aadf12a40b4c3f52fa2
(SWISE)0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
(USDC)0xdac17f958d2ee523a2206206994597c13d831ec7
(USDT)0x2260fac5e5542a773aa44fbcfedf7c193bc2c599
(WBTC)0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2
(WETH)0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0
(wstETH)
And for CowswapOrderSigner.signOrder.buyToken
:
0x6b175474e89094c44da98b954eedeac495271d0f
(DAI)0xae78736cd615f374d3085123a210448e74fc6393
(rETH)0xfe2e637202056d30016725477c5da089ab0a043a
(sETH2)0xae7ab96520de3a18e5e111b5eaab095312d7fe84
(stETH)0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
(USDC)0xdac17f958d2ee523a2206206994597c13d831ec7
(USDT)0x2260fac5e5542a773aa44fbcfedf7c193bc2c599
(WBTC)0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2
(WETH)0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0
(wstETH)
Conclusion
The INRM token approval is puzzling but not necessarily a security issue.
For the CoW swap order signing however, I would recommend either (1) a scope on the feeAmountBP
parameter or (2) a new (official) deployment of the signer contract which has a tighter sanity check on the fee (ref) to prevent getting assets drained through unrealistic fees.