diff --git a/README.md b/README.md index 2dd48ed..98ebc46 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ docker run --rm -it \ | [`UniswapPublicCallback`][public_callback] | callers of callback functions are not exclusively
restricted to the contract itself | High | High | | [`UniswapUpgradableHook`][upgradable_hook] | the contract `DELEGATECALL`s to mutable addresses | High | High | | [`UniswapSuicidalHook`][suicidal_hook] | the contract contains `SELFDESTRUCT` | Medium | High | +| [`UniswapRugHook`][rug_hook] | the hook is a possible rug pull contract | High | Medium | ## Evaluation @@ -94,6 +95,7 @@ The test results are as follows: | [`UniswapPublicCallback`][public_callback] | 3/3 contracts | | [`UniswapUpgradableHook`][upgradable_hook] | 0 | | [`UniswapSuicidalHook`][suicidal_hook] | 0 | +| [`UniswapRugHook`][rug_hook] | N/A | ## Note @@ -111,3 +113,4 @@ This project is under the AGPLv3 License. See the LICENSE file for the full lice [public_hook]: docs/detectors/UniswapPublicHook.md [upgradable_hook]: docs/detectors/UniswapUpgradableHook.md [suicidal_hook]: docs/detectors/UniswapSuicidalHook.md +[rug_hook]: docs/detectors/UniswapRugHook.md diff --git a/docs/detectors/UniswapRugHook.md b/docs/detectors/UniswapRugHook.md new file mode 100644 index 0000000..341174a --- /dev/null +++ b/docs/detectors/UniswapRugHook.md @@ -0,0 +1,12 @@ +# UniswapSuicidalHook + +## Info + +**Spec** + +- Severity: High +- Confidence: Medium + +**Description** + +Find potential rug pull contracts by detecting instances where native tokens or ERC tokens are transferred to an address controlled by the owner within a privileged function. diff --git a/hookscan/detectors/all_detectors.py b/hookscan/detectors/all_detectors.py index 4634c35..33daabf 100644 --- a/hookscan/detectors/all_detectors.py +++ b/hookscan/detectors/all_detectors.py @@ -2,6 +2,7 @@ from hookscan.detectors.uniswap_hook.uniswap_public_callback import UniswapPublicCallback from hookscan.detectors.uniswap_hook.uniswap_public_hook import UniswapPublicHook +from hookscan.detectors.uniswap_hook.uniswap_rug_hook import UniswapRugHook from hookscan.detectors.uniswap_hook.uniswap_suicidal_hook import UniswapSuicidalHook from hookscan.detectors.uniswap_hook.uniswap_upgradable_hook import UniswapUpgradableHook @@ -10,6 +11,7 @@ UniswapPublicHook, UniswapSuicidalHook, UniswapUpgradableHook, + UniswapRugHook, ] all_detectors_dict: Dict[str, type] = {detector.__name__: detector for detector in all_detectors} diff --git a/hookscan/detectors/base_detector.py b/hookscan/detectors/base_detector.py index 65c08e6..a151c05 100644 --- a/hookscan/detectors/base_detector.py +++ b/hookscan/detectors/base_detector.py @@ -194,3 +194,27 @@ def taint_in( elif taint == target_taint: return True return False + + def get_all_hooked_instances( + self, info: "TraversalInfo", target_inst_types: Optional[List[Type[Instruction]]] = None + ) -> List[ValueInstance]: + def valid_target(target_inst_types: List[Type[Instruction]]): + for key1 in target_inst_types: + for key2 in target_inst_types: + if key1 == key2: + continue + assert not (issubclass(key1, key2) or issubclass(key2, key1)) + + hooked_instances = [] + inst_types = target_inst_types if target_inst_types is not None else list(self.callback_keys) + + valid_target(inst_types) + + for path_index, inst_index in info.trigger_index_list: + path_node = info.path[path_index] + inst_index -= path_node.start_index + inst_instance = path_node.inst_instances[inst_index] + for consider_inst_type in inst_types: + if isinstance(inst_instance.value, consider_inst_type): + hooked_instances.append(inst_instance) + return hooked_instances diff --git a/hookscan/detectors/uniswap_hook/uniswap_rug_hook.py b/hookscan/detectors/uniswap_hook/uniswap_rug_hook.py new file mode 100644 index 0000000..670aac8 --- /dev/null +++ b/hookscan/detectors/uniswap_hook/uniswap_rug_hook.py @@ -0,0 +1,55 @@ +# NOTE THIS IS PROTOTYPE + +from typing import Dict, List + +from hookscan.components.constant import ConstantInt +from hookscan.components.evm_instructions import Call, Callcode +from hookscan.components.value import Value +from hookscan.core.instruction_instance import ValueInstance +from hookscan.core.traversal_info import TraversalInfo +from hookscan.detectors.base_detector import BaseDetector +from hookscan.detectors.detector_result import DetectorResult + + +class UniswapRugHook(BaseDetector): + """Transfer to a controlled (by owner) address in a privilege function.""" + + VULNERABILITY_DESCRIPTION = "possible rug-pull hook" + + def __init__(self) -> None: + super().__init__() + self._result: Dict[Value, DetectorResult] = {} + self.callback_keys = ( + Call, + Callcode, + ) + + def callback(self, info: TraversalInfo, inst_instance: ValueInstance, is_end: bool): + if not info.function.is_runtime: + return + if is_end: + if not info.is_protected: + return # rug function should be privileged + for transfer_inst in self.get_all_hooked_instances(info): + if isinstance(transfer_inst_value := transfer_inst.value, (Call, Callcode)): + if self.get_call_signature(transfer_inst) in { # token transfer, amount == 0 not considered + 0xA9059CBB, # erc20 transfer(address,uint256) + 0x23B872DD, # erc20 transferFrom(address,address,uint256) + 0x42842E0E, # erc721 safeTransferFrom(address,address,uint256) + 0xB88D4FDE, # erc721 safeTransferFrom(address,address,uint256,bytes) + 0x23B872DD, # erc721 transferFrom(address,address,uint256) + 0x2EB2C2D6, # erc1155 safeBatchTransferFrom(address,address,uint256[],uint256[],bytes) + 0xF242432A, # erc1155 safeTransferFrom(address,address,uint256,uint256,bytes) + } or not ( # native call: msg.value != 0 + isinstance(call_value := transfer_inst.operand_instances[2].origin.value, ConstantInt) + and call_value.value == 0 + ): + self._result[transfer_inst_value] = DetectorResult( + target=transfer_inst, severity="high", confidence="medium" + ) + + def get_internal_result(self) -> List[DetectorResult]: + return list(self._result.values()) + + def get_external_result(self) -> List[DetectorResult]: + return self.get_internal_result() diff --git a/tests/unsafe/UniswapRugHook.sol b/tests/unsafe/UniswapRugHook.sol new file mode 100644 index 0000000..120c61b --- /dev/null +++ b/tests/unsafe/UniswapRugHook.sol @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.1; + +type Currency is address; +type BalanceDelta is int256; + +struct PoolKey { + Currency currency0; + Currency currency1; + // address currency0; + // address currency1; + uint24 fee; + int24 tickSpacing; + IHooks hooks; +} + +interface IPoolManager { + struct ModifyPositionParams { + int24 tickLower; + int24 tickUpper; + int256 liquidityDelta; + } + + struct SwapParams { + bool zeroForOne; + int256 amountSpecified; + uint160 sqrtPriceLimitX96; + } + + function lock(bytes calldata data) external returns (bytes memory); +} + +interface IHooks { + function beforeInitialize( + address sender, + PoolKey calldata key, + uint160 sqrtPriceX96, + bytes calldata hookData + ) external returns (bytes4); + + function afterInitialize( + address sender, + PoolKey calldata key, + uint160 sqrtPriceX96, + int24 tick, + bytes calldata hookData + ) external returns (bytes4); + + function beforeModifyPosition( + address sender, + PoolKey calldata key, + IPoolManager.ModifyPositionParams calldata params, + bytes calldata hookData + ) external returns (bytes4); + + function afterModifyPosition( + address sender, + PoolKey calldata key, + IPoolManager.ModifyPositionParams calldata params, + BalanceDelta delta, + // int256 delta, + bytes calldata hookData + ) external returns (bytes4); + + function beforeSwap( + address sender, + PoolKey calldata key, + IPoolManager.SwapParams calldata params, + bytes calldata hookData + ) external returns (bytes4); + + function afterSwap( + address sender, + PoolKey calldata key, + IPoolManager.SwapParams calldata params, + BalanceDelta delta, + // int256 delta, + bytes calldata hookData + ) external returns (bytes4); + + function beforeDonate( + address sender, + PoolKey calldata key, + uint256 amount0, + uint256 amount1, + bytes calldata hookData + ) external returns (bytes4); + + function afterDonate( + address sender, + PoolKey calldata key, + uint256 amount0, + uint256 amount1, + bytes calldata hookData + ) external returns (bytes4); +} + +abstract contract BaseHook is IHooks { + error NotPoolManager(); + error NotSelf(); + error InvalidPool(); + error LockFailure(); + error HookNotImplemented(); + + IPoolManager public immutable poolManager; + + constructor(IPoolManager _poolManager) { + poolManager = _poolManager; + } + + modifier poolManagerOnly() { + if (msg.sender != address(poolManager)) revert NotPoolManager(); + _; + } + + modifier selfOnly() { + if (msg.sender != address(this)) revert NotSelf(); + _; + } + + modifier onlyValidPools(IHooks hooks) { + if (hooks != this) revert InvalidPool(); + _; + } + + function lockAcquired( + bytes calldata data + ) external virtual poolManagerOnly returns (bytes memory) { + (bool success, bytes memory returnData) = address(this).call(data); + if (success) return returnData; + if (returnData.length == 0) revert LockFailure(); + assembly { + revert(add(returnData, 32), mload(returnData)) + } + } + + function beforeInitialize( + address, + PoolKey calldata, + uint160, + bytes calldata + ) external virtual returns (bytes4) { + revert HookNotImplemented(); + } + + function afterInitialize( + address, + PoolKey calldata, + uint160, + int24, + bytes calldata + ) external virtual returns (bytes4) { + revert HookNotImplemented(); + } + + function beforeModifyPosition( + address, + PoolKey calldata, + IPoolManager.ModifyPositionParams calldata, + bytes calldata + ) external virtual returns (bytes4) { + revert HookNotImplemented(); + } + + function afterModifyPosition( + address, + PoolKey calldata, + IPoolManager.ModifyPositionParams calldata, + BalanceDelta, + // int256, + bytes calldata + ) external virtual returns (bytes4) { + revert HookNotImplemented(); + } + + function beforeSwap( + address, + PoolKey calldata, + IPoolManager.SwapParams calldata, + bytes calldata + ) external virtual returns (bytes4) { + revert HookNotImplemented(); + } + + function afterSwap( + address, + PoolKey calldata, + IPoolManager.SwapParams calldata, + BalanceDelta, + // int256, + bytes calldata + ) external virtual returns (bytes4) { + revert HookNotImplemented(); + } + + function beforeDonate( + address, + PoolKey calldata, + uint256, + uint256, + bytes calldata + ) external virtual returns (bytes4) { + revert HookNotImplemented(); + } + + function afterDonate( + address, + PoolKey calldata, + uint256, + uint256, + bytes calldata + ) external virtual returns (bytes4) { + revert HookNotImplemented(); + } +} + +interface Token { + function transfer(address, uint256) external returns (bool); + + function balanceOf(address) external view returns (uint256); +} + +contract Hook is BaseHook { + uint count; + address owner; + Token token; + + constructor( + IPoolManager _poolManager, + address _owner, + address _token + ) BaseHook(_poolManager) { + owner = _owner; + token = Token(_token); + } + + function beforeSwap( + address, + PoolKey calldata, + IPoolManager.SwapParams calldata, + bytes calldata + ) external override poolManagerOnly returns (bytes4) { + count++; + return IHooks.beforeSwap.selector; + } + + // ... functions including logic of receiving ether and tokens + + function clear_token() external { + require(msg.sender == owner, "owner only"); + token.transfer(owner, token.balanceOf(owner)); + } + + function clear_eth() external { + require(msg.sender == owner, "owner only"); + payable(owner).transfer(address(this).balance); + } +}