Skip to content

Commit e863fbc

Browse files
authored
Merge pull request #2584 from crytic/dev-chronicle-price-detector
Add Chronicle unchecked price detector
2 parents 32f3ba5 + efc45f0 commit e863fbc

File tree

6 files changed

+290
-0
lines changed

6 files changed

+290
-0
lines changed

slither/detectors/all_detectors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
from .statements.tautological_compare import TautologicalCompare
9898
from .statements.return_bomb import ReturnBomb
9999
from .functions.out_of_order_retryable import OutOfOrderRetryable
100+
from .statements.chronicle_unchecked_price import ChronicleUncheckedPrice
100101
from .statements.pyth_unchecked_confidence import PythUncheckedConfidence
101102
from .statements.pyth_unchecked_publishtime import PythUncheckedPublishTime
102103
from .functions.chainlink_feed_registry import ChainlinkFeedRegistry
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from typing import List
2+
3+
from slither.detectors.abstract_detector import (
4+
AbstractDetector,
5+
DetectorClassification,
6+
DETECTOR_INFO,
7+
)
8+
from slither.utils.output import Output
9+
from slither.slithir.operations import Binary, Assignment, Unpack, SolidityCall
10+
from slither.core.variables import Variable
11+
from slither.core.declarations.solidity_variables import SolidityFunction
12+
from slither.core.cfg.node import Node
13+
14+
15+
class ChronicleUncheckedPrice(AbstractDetector):
16+
"""
17+
Documentation: This detector finds calls to Chronicle oracle where the returned price is not checked
18+
https://docs.chroniclelabs.org/Resources/FAQ/Oracles#how-do-i-check-if-an-oracle-becomes-inactive-gets-deprecated
19+
"""
20+
21+
ARGUMENT = "chronicle-unchecked-price"
22+
HELP = "Detect when Chronicle price is not checked."
23+
IMPACT = DetectorClassification.MEDIUM
24+
CONFIDENCE = DetectorClassification.MEDIUM
25+
26+
WIKI = "https://github.com/crytic/slither/wiki/Detector-Documentation#chronicle-unchecked-price"
27+
28+
WIKI_TITLE = "Chronicle unchecked price"
29+
WIKI_DESCRIPTION = "Chronicle oracle is used and the price returned is not checked to be valid. For more information https://docs.chroniclelabs.org/Resources/FAQ/Oracles#how-do-i-check-if-an-oracle-becomes-inactive-gets-deprecated."
30+
31+
# region wiki_exploit_scenario
32+
WIKI_EXPLOIT_SCENARIO = """
33+
```solidity
34+
contract C {
35+
IChronicle chronicle;
36+
37+
constructor(address a) {
38+
chronicle = IChronicle(a);
39+
}
40+
41+
function bad() public {
42+
uint256 price = chronicle.read();
43+
}
44+
```
45+
The `bad` function gets the price from Chronicle by calling the read function however it does not check if the price is valid."""
46+
# endregion wiki_exploit_scenario
47+
48+
WIKI_RECOMMENDATION = "Validate that the price returned by the oracle is valid."
49+
50+
def _var_is_checked(self, nodes: List[Node], var_to_check: Variable) -> bool:
51+
visited = set()
52+
checked = False
53+
54+
while nodes:
55+
if checked:
56+
break
57+
next_node = nodes[0]
58+
nodes = nodes[1:]
59+
60+
for node_ir in next_node.all_slithir_operations():
61+
if isinstance(node_ir, Binary) and var_to_check in node_ir.read:
62+
checked = True
63+
break
64+
# This case is for tryRead and tryReadWithAge
65+
# if the isValid boolean is checked inside a require(isValid)
66+
if (
67+
isinstance(node_ir, SolidityCall)
68+
and node_ir.function
69+
in (
70+
SolidityFunction("require(bool)"),
71+
SolidityFunction("require(bool,string)"),
72+
SolidityFunction("require(bool,error)"),
73+
)
74+
and var_to_check in node_ir.read
75+
):
76+
checked = True
77+
break
78+
79+
if next_node not in visited:
80+
visited.add(next_node)
81+
for son in next_node.sons:
82+
if son not in visited:
83+
nodes.append(son)
84+
return checked
85+
86+
# pylint: disable=too-many-nested-blocks,too-many-branches
87+
def _detect(self) -> List[Output]:
88+
results: List[Output] = []
89+
90+
for contract in self.compilation_unit.contracts_derived:
91+
for target_contract, ir in sorted(
92+
contract.all_high_level_calls,
93+
key=lambda x: (x[1].node.node_id, x[1].node.function.full_name),
94+
):
95+
if target_contract.name in ("IScribe", "IChronicle") and ir.function_name in (
96+
"read",
97+
"tryRead",
98+
"readWithAge",
99+
"tryReadWithAge",
100+
"latestAnswer",
101+
"latestRoundData",
102+
):
103+
found = False
104+
if ir.function_name in ("read", "latestAnswer"):
105+
# We need to iterate the IRs as we are not always sure that the following IR is the assignment
106+
# for example in case of type conversion it isn't
107+
for node_ir in ir.node.irs:
108+
if isinstance(node_ir, Assignment):
109+
possible_unchecked_variable_ir = node_ir.lvalue
110+
found = True
111+
break
112+
elif ir.function_name in ("readWithAge", "tryRead", "tryReadWithAge"):
113+
# We are interested in the first item of the tuple
114+
# readWithAge : value
115+
# tryRead/tryReadWithAge : isValid
116+
for node_ir in ir.node.irs:
117+
if isinstance(node_ir, Unpack) and node_ir.index == 0:
118+
possible_unchecked_variable_ir = node_ir.lvalue
119+
found = True
120+
break
121+
elif ir.function_name == "latestRoundData":
122+
found = False
123+
for node_ir in ir.node.irs:
124+
if isinstance(node_ir, Unpack) and node_ir.index == 1:
125+
possible_unchecked_variable_ir = node_ir.lvalue
126+
found = True
127+
break
128+
129+
# If we did not find the variable assignment we know it's not checked
130+
checked = (
131+
self._var_is_checked(ir.node.sons, possible_unchecked_variable_ir)
132+
if found
133+
else False
134+
)
135+
136+
if not checked:
137+
info: DETECTOR_INFO = [
138+
"Chronicle price is not checked to be valid in ",
139+
ir.node.function,
140+
"\n\t- ",
141+
ir.node,
142+
"\n",
143+
]
144+
res = self.generate_result(info)
145+
results.append(res)
146+
147+
return results
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
Chronicle price is not checked to be valid in C.bad2() (tests/e2e/detectors/test_data/chronicle-unchecked-price/0.8.20/chronicle_unchecked_price.sol#74-76)
2+
- (price,None) = chronicle.readWithAge() (tests/e2e/detectors/test_data/chronicle-unchecked-price/0.8.20/chronicle_unchecked_price.sol#75)
3+
4+
Chronicle price is not checked to be valid in C.bad() (tests/e2e/detectors/test_data/chronicle-unchecked-price/0.8.20/chronicle_unchecked_price.sol#65-67)
5+
- price = chronicle.read() (tests/e2e/detectors/test_data/chronicle-unchecked-price/0.8.20/chronicle_unchecked_price.sol#66)
6+
7+
Chronicle price is not checked to be valid in C.bad5() (tests/e2e/detectors/test_data/chronicle-unchecked-price/0.8.20/chronicle_unchecked_price.sol#101-103)
8+
- price = scribe.latestAnswer() (tests/e2e/detectors/test_data/chronicle-unchecked-price/0.8.20/chronicle_unchecked_price.sol#102)
9+
10+
Chronicle price is not checked to be valid in C.bad4() (tests/e2e/detectors/test_data/chronicle-unchecked-price/0.8.20/chronicle_unchecked_price.sol#92-94)
11+
- (isValid,price,None) = chronicle.tryReadWithAge() (tests/e2e/detectors/test_data/chronicle-unchecked-price/0.8.20/chronicle_unchecked_price.sol#93)
12+
13+
Chronicle price is not checked to be valid in C.bad3() (tests/e2e/detectors/test_data/chronicle-unchecked-price/0.8.20/chronicle_unchecked_price.sol#83-85)
14+
- (isValid,price) = chronicle.tryRead() (tests/e2e/detectors/test_data/chronicle-unchecked-price/0.8.20/chronicle_unchecked_price.sol#84)
15+
16+
Chronicle price is not checked to be valid in C.bad6() (tests/e2e/detectors/test_data/chronicle-unchecked-price/0.8.20/chronicle_unchecked_price.sol#110-112)
17+
- (None,price,None,None,None) = scribe.latestRoundData() (tests/e2e/detectors/test_data/chronicle-unchecked-price/0.8.20/chronicle_unchecked_price.sol#111)
18+
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
interface IChronicle {
2+
/// @notice Returns the oracle's current value.
3+
/// @dev Reverts if no value set.
4+
/// @return value The oracle's current value.
5+
function read() external view returns (uint value);
6+
7+
/// @notice Returns the oracle's current value and its age.
8+
/// @dev Reverts if no value set.
9+
/// @return value The oracle's current value.
10+
/// @return age The value's age.
11+
function readWithAge() external view returns (uint value, uint age);
12+
13+
/// @notice Returns the oracle's current value.
14+
/// @return isValid True if value exists, false otherwise.
15+
/// @return value The oracle's current value if it exists, zero otherwise.
16+
function tryRead() external view returns (bool isValid, uint value);
17+
18+
/// @notice Returns the oracle's current value and its age.
19+
/// @return isValid True if value exists, false otherwise.
20+
/// @return value The oracle's current value if it exists, zero otherwise.
21+
/// @return age The value's age if value exists, zero otherwise.
22+
function tryReadWithAge()
23+
external
24+
view
25+
returns (bool isValid, uint value, uint age);
26+
}
27+
28+
interface IScribe is IChronicle {
29+
/// @notice Returns the oracle's latest value.
30+
/// @dev Provides partial compatibility with Chainlink's
31+
/// IAggregatorV3Interface.
32+
/// @return roundId 1.
33+
/// @return answer The oracle's latest value.
34+
/// @return startedAt 0.
35+
/// @return updatedAt The timestamp of oracle's latest update.
36+
/// @return answeredInRound 1.
37+
function latestRoundData()
38+
external
39+
view
40+
returns (
41+
uint80 roundId,
42+
int answer,
43+
uint startedAt,
44+
uint updatedAt,
45+
uint80 answeredInRound
46+
);
47+
48+
/// @notice Returns the oracle's latest value.
49+
/// @dev Provides partial compatibility with Chainlink's
50+
/// IAggregatorV3Interface.
51+
/// @custom:deprecated See https://docs.chain.link/data-feeds/api-reference/#latestanswer.
52+
/// @return answer The oracle's latest value.
53+
function latestAnswer() external view returns (int);
54+
}
55+
56+
contract C {
57+
IScribe scribe;
58+
IChronicle chronicle;
59+
60+
constructor(address a) {
61+
scribe = IScribe(a);
62+
chronicle = IChronicle(a);
63+
}
64+
65+
function bad() public {
66+
uint256 price = chronicle.read();
67+
}
68+
69+
function good() public {
70+
uint256 price = chronicle.read();
71+
require(price != 0);
72+
}
73+
74+
function bad2() public {
75+
(uint256 price,) = chronicle.readWithAge();
76+
}
77+
78+
function good2() public {
79+
(uint256 price,) = chronicle.readWithAge();
80+
require(price != 0);
81+
}
82+
83+
function bad3() public {
84+
(bool isValid, uint256 price) = chronicle.tryRead();
85+
}
86+
87+
function good3() public {
88+
(bool isValid, uint256 price) = chronicle.tryRead();
89+
require(isValid);
90+
}
91+
92+
function bad4() public {
93+
(bool isValid, uint256 price,) = chronicle.tryReadWithAge();
94+
}
95+
96+
function good4() public {
97+
(bool isValid, uint256 price,) = chronicle.tryReadWithAge();
98+
require(isValid);
99+
}
100+
101+
function bad5() public {
102+
int256 price = scribe.latestAnswer();
103+
}
104+
105+
function good5() public {
106+
int256 price = scribe.latestAnswer();
107+
require(price != 0);
108+
}
109+
110+
function bad6() public {
111+
(, int256 price,,,) = scribe.latestRoundData();
112+
}
113+
114+
function good6() public {
115+
(, int256 price,,,) = scribe.latestRoundData();
116+
require(price != 0);
117+
}
118+
119+
}

tests/e2e/detectors/test_detectors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1714,6 +1714,11 @@ def id_test(test_item: Test):
17141714
"out_of_order_retryable.sol",
17151715
"0.8.20",
17161716
),
1717+
Test(
1718+
all_detectors.ChronicleUncheckedPrice,
1719+
"chronicle_unchecked_price.sol",
1720+
"0.8.20",
1721+
),
17171722
Test(
17181723
all_detectors.PythUncheckedConfidence,
17191724
"pyth_unchecked_confidence.sol",

0 commit comments

Comments
 (0)