Skip to content

Commit 4006404

Browse files
ep0chzer0dguido
andauthored
feat: add msg-value-in-nonpayable detector (#2923)
* feat: add msg-value-in-nonpayable detector Implements #2781. Detects uses of msg.value in functions that cannot be reached from any payable entry point. - Uses call graph analysis via all_reachable_from_functions - Properly formats output using Slither objects for JSON/SARIF - Includes test cases for internal function call chains - Limited to Solidity (LANGUAGE = "solidity") Note: Solidity 0.8+ prevents direct msg.value use in non-payable public/external functions at compile time. This detector catches internal functions that use msg.value but are only reachable from non-payable entry points. * fix: update type annotations and add test artifacts - Replace deprecated typing.List/Tuple with builtin list/tuple (ruff UP035/UP006) - Add from __future__ import annotations for PEP 604 support - Add missing test zip artifact and snapshot file * fix: correct snapshot order for msg-value-in-nonpayable detector --------- Co-authored-by: ep0chzer0 <[email protected]> Co-authored-by: Dan Guido <[email protected]>
1 parent de200d6 commit 4006404

File tree

6 files changed

+236
-0
lines changed

6 files changed

+236
-0
lines changed

slither/detectors/all_detectors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
from .functions.dead_code import DeadCode
8484
from .statements.write_after_write import WriteAfterWrite
8585
from .statements.msg_value_in_loop import MsgValueInLoop
86+
from .statements.msg_value_in_nonpayable import MsgValueInNonPayable
8687
from .statements.delegatecall_in_loop import DelegatecallInLoop
8788
from .functions.protected_variable import ProtectedVariables
8889
from .functions.permit_domain_signature_collision import DomainSeparatorCollision
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""
2+
Detector for msg.value usage in functions unreachable from payable entry points.
3+
Related to issue #2781.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from slither.utils.output import Output
9+
from slither.detectors.abstract_detector import (
10+
AbstractDetector,
11+
DetectorClassification,
12+
DETECTOR_INFO,
13+
)
14+
from slither.core.declarations.solidity_variables import SolidityVariableComposed
15+
from slither.core.declarations.function import Function
16+
17+
18+
class MsgValueInNonPayable(AbstractDetector):
19+
"""
20+
Detects uses of msg.value in functions that cannot be reached
21+
from any payable public/external entry point.
22+
23+
Such msg.value usages are provably meaningless:
24+
- msg.value will always be 0, or
25+
- execution will always revert
26+
"""
27+
28+
ARGUMENT = "msg-value-in-nonpayable"
29+
HELP = "msg.value used in functions unreachable from payable entry points"
30+
IMPACT = DetectorClassification.HIGH
31+
CONFIDENCE = DetectorClassification.HIGH
32+
33+
WIKI = "https://github.com/crytic/slither/wiki/Detector-Documentation#msgvalue-in-nonpayable"
34+
WIKI_TITLE = "msg.value in non-payable function"
35+
36+
WIKI_DESCRIPTION = """
37+
Detects functions that read `msg.value` but cannot be reached from any payable
38+
public or external entry point. In such cases, `msg.value` is always zero or
39+
execution always reverts."""
40+
41+
WIKI_EXPLOIT_SCENARIO = """
42+
```solidity
43+
contract Subscription {
44+
mapping(address => bool) public subscribed;
45+
46+
function subscribe() external {
47+
require(msg.value >= 1 ether, "Fee required");
48+
subscribed[msg.sender] = true;
49+
}
50+
}
51+
```
52+
`subscribe()` is not payable, so it cannot receive ETH. `msg.value` is always zero
53+
and the require always fails, making the function unusable."""
54+
55+
WIKI_RECOMMENDATION = """
56+
Either mark the function as `payable` if it should receive ETH, or remove the
57+
`msg.value` check since it will always be zero in non-payable contexts."""
58+
59+
# Only applicable to Solidity (Vyper handles payable differently)
60+
LANGUAGE = "solidity"
61+
62+
def _detect(self) -> list[Output]:
63+
results = []
64+
65+
for contract in self.compilation_unit.contracts_derived:
66+
for func in contract.functions:
67+
# Skip if not implemented
68+
if not func.is_implemented:
69+
continue
70+
71+
# Check if function or its modifiers use msg.value
72+
if not self._uses_msg_value(func) and not any(
73+
self._uses_msg_value(m) for m in func.modifiers
74+
):
75+
continue
76+
77+
# Payable functions are valid entry points for msg.value
78+
if func.payable:
79+
continue
80+
81+
# Get all entry points that can reach this function
82+
payable_callers, non_payable_callers = self._get_entry_point_callers(func)
83+
84+
# If any payable entry point reaches this function, msg.value is valid
85+
if payable_callers:
86+
continue
87+
88+
# Otherwise, msg.value is unreachable from payable contexts - flag it
89+
info: DETECTOR_INFO = self._build_info(func, non_payable_callers)
90+
results.append(self.generate_result(info))
91+
92+
return results
93+
94+
def _uses_msg_value(self, function: Function) -> bool:
95+
"""Returns True if the function directly reads msg.value."""
96+
for node in function.nodes:
97+
for ir in node.irs:
98+
for read in ir.read:
99+
if isinstance(read, SolidityVariableComposed) and read.name == "msg.value":
100+
return True
101+
return False
102+
103+
def _get_entry_point_callers(self, function: Function) -> tuple[list[Function], list[Function]]:
104+
"""
105+
Walk the reverse call graph to find all public/external entry points.
106+
107+
Returns:
108+
(payable_callers, non_payable_callers) - only public/external functions
109+
"""
110+
payable_callers = []
111+
non_payable_callers = []
112+
113+
# Include the function itself if it's an entry point
114+
if function.visibility in ("public", "external"):
115+
if function.payable:
116+
payable_callers.append(function)
117+
else:
118+
non_payable_callers.append(function)
119+
120+
# Check all functions that can reach this one
121+
for caller in function.all_reachable_from_functions:
122+
if not isinstance(caller, Function):
123+
continue
124+
if caller.visibility not in ("public", "external"):
125+
continue
126+
127+
if caller.payable:
128+
payable_callers.append(caller)
129+
else:
130+
non_payable_callers.append(caller)
131+
132+
return payable_callers, non_payable_callers
133+
134+
def _build_info(self, func: Function, non_payable_callers: list[Function]) -> DETECTOR_INFO:
135+
"""Build properly formatted detector output using Slither objects."""
136+
info: DETECTOR_INFO = [
137+
func,
138+
" uses msg.value but is not reachable from any payable function\n",
139+
]
140+
141+
if non_payable_callers:
142+
info.append("\tNon-payable entry points that can reach this function:\n")
143+
for caller in non_payable_callers:
144+
if caller != func:
145+
info.extend(["\t- ", caller, "\n"])
146+
147+
return info
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
TestMsgValueInNonPayable._helper() (tests/e2e/detectors/test_data/msg-value-in-nonpayable/0.8.0/msg_value_in_nonpayable.sol#21-23) uses msg.value but is not reachable from any payable function
2+
Non-payable entry points that can reach this function:
3+
- TestMsgValueInNonPayable.bad2() (tests/e2e/detectors/test_data/msg-value-in-nonpayable/0.8.0/msg_value_in_nonpayable.sol#29-31)
4+
5+
TestMsgValueInNonPayable._addBalance() (tests/e2e/detectors/test_data/msg-value-in-nonpayable/0.8.0/msg_value_in_nonpayable.sol#12-14) uses msg.value but is not reachable from any payable function
6+
Non-payable entry points that can reach this function:
7+
- TestMsgValueInNonPayable.bad1() (tests/e2e/detectors/test_data/msg-value-in-nonpayable/0.8.0/msg_value_in_nonpayable.sol#16-18)
8+
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.0;
3+
4+
// In Solidity 0.8+, the compiler prevents msg.value in non-payable public/external functions.
5+
// But it doesn't catch INTERNAL functions that use msg.value when only called from non-payable entry points.
6+
// This detector catches those cases.
7+
8+
contract TestMsgValueInNonPayable {
9+
mapping(address => uint256) public balances;
10+
11+
// BAD: Internal function using msg.value, only called from non-payable
12+
function _addBalance() internal {
13+
balances[msg.sender] += msg.value;
14+
}
15+
16+
function bad1() external {
17+
_addBalance();
18+
}
19+
20+
// BAD: Internal helper in call chain with no payable entry
21+
function _helper() internal view returns (uint256) {
22+
return msg.value;
23+
}
24+
25+
function _middle() internal view returns (uint256) {
26+
return _helper();
27+
}
28+
29+
function bad2() external view returns (uint256) {
30+
return _middle();
31+
}
32+
33+
// GOOD: Internal function called from payable entry point
34+
function _processPayment() internal {
35+
balances[msg.sender] += msg.value;
36+
}
37+
38+
function good1() external payable {
39+
_processPayment();
40+
}
41+
42+
// GOOD: Internal function reachable from at least one payable function
43+
function _sharedHelper() internal view returns (uint256) {
44+
return msg.value;
45+
}
46+
47+
function good2_nonpayable() external view returns (uint256) {
48+
return _sharedHelper();
49+
}
50+
51+
function good2_payable() external payable returns (uint256) {
52+
return _sharedHelper();
53+
}
54+
}
55+
56+
contract TestMultipleCallers {
57+
uint256 public value;
58+
59+
// Internal function with multiple callers - one payable
60+
function _setValue() internal {
61+
value = msg.value;
62+
}
63+
64+
// Non-payable caller (alone would be BAD)
65+
function caller1() external {
66+
_setValue();
67+
}
68+
69+
// Payable caller - makes _setValue GOOD
70+
function caller2() external payable {
71+
_setValue();
72+
}
73+
74+
// _setValue should NOT be flagged because caller2 is payable
75+
}
Binary file not shown.

tests/e2e/detectors/test_detectors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1487,6 +1487,11 @@ def id_test(test_item: Test):
14871487
"msg_value_loop.sol",
14881488
"0.8.0",
14891489
),
1490+
Test(
1491+
all_detectors.MsgValueInNonPayable,
1492+
"msg_value_in_nonpayable.sol",
1493+
"0.8.0",
1494+
),
14901495
Test(
14911496
all_detectors.DelegatecallInLoop,
14921497
"delegatecall_loop.sol",

0 commit comments

Comments
 (0)