mirror of https://github.com/crytic/slither
commit
bc79beec5e
@ -0,0 +1,91 @@ |
||||
from typing import List, Optional |
||||
|
||||
from slither.core.declarations import SolidityFunction, Function |
||||
from slither.detectors.abstract_detector import ( |
||||
AbstractDetector, |
||||
DetectorClassification, |
||||
DETECTOR_INFO, |
||||
) |
||||
from slither.slithir.operations import SolidityCall |
||||
from slither.utils.output import Output |
||||
|
||||
|
||||
def _assembly_node(function: Function) -> Optional[SolidityCall]: |
||||
""" |
||||
Check if there is a node that use return in assembly |
||||
|
||||
Args: |
||||
function: |
||||
|
||||
Returns: |
||||
|
||||
""" |
||||
|
||||
for ir in function.all_slithir_operations(): |
||||
if isinstance(ir, SolidityCall) and ir.function == SolidityFunction( |
||||
"return(uint256,uint256)" |
||||
): |
||||
return ir |
||||
return None |
||||
|
||||
|
||||
class IncorrectReturn(AbstractDetector): |
||||
""" |
||||
Check for cases where a return(a,b) is used in an assembly function |
||||
""" |
||||
|
||||
ARGUMENT = "incorrect-return" |
||||
HELP = "If a `return` is incorrectly used in assembly mode." |
||||
IMPACT = DetectorClassification.HIGH |
||||
CONFIDENCE = DetectorClassification.MEDIUM |
||||
|
||||
WIKI = "https://github.com/crytic/slither/wiki/Detector-Documentation#incorrect-assembly-return" |
||||
|
||||
WIKI_TITLE = "Incorrect return in assembly" |
||||
WIKI_DESCRIPTION = "Detect if `return` in an assembly block halts unexpectedly the execution." |
||||
WIKI_EXPLOIT_SCENARIO = """ |
||||
```solidity |
||||
contract C { |
||||
function f() internal returns (uint a, uint b) { |
||||
assembly { |
||||
return (5, 6) |
||||
} |
||||
} |
||||
|
||||
function g() returns (bool){ |
||||
f(); |
||||
return true; |
||||
} |
||||
} |
||||
``` |
||||
The return statement in `f` will cause execution in `g` to halt. |
||||
The function will return 6 bytes starting from offset 5, instead of returning a boolean.""" |
||||
|
||||
WIKI_RECOMMENDATION = "Use the `leave` statement." |
||||
|
||||
# pylint: disable=too-many-nested-blocks |
||||
def _detect(self) -> List[Output]: |
||||
results: List[Output] = [] |
||||
for c in self.contracts: |
||||
for f in c.functions_and_modifiers_declared: |
||||
|
||||
for node in f.nodes: |
||||
if node.sons: |
||||
for function_called in node.internal_calls: |
||||
if isinstance(function_called, Function): |
||||
found = _assembly_node(function_called) |
||||
if found: |
||||
|
||||
info: DETECTOR_INFO = [ |
||||
f, |
||||
" calls ", |
||||
function_called, |
||||
" which halt the execution ", |
||||
found.node, |
||||
"\n", |
||||
] |
||||
json = self.generate_result(info) |
||||
|
||||
results.append(json) |
||||
|
||||
return results |
@ -0,0 +1,68 @@ |
||||
from typing import List |
||||
|
||||
from slither.core.declarations import SolidityFunction, Function |
||||
from slither.detectors.abstract_detector import ( |
||||
AbstractDetector, |
||||
DetectorClassification, |
||||
DETECTOR_INFO, |
||||
) |
||||
from slither.slithir.operations import SolidityCall |
||||
from slither.utils.output import Output |
||||
|
||||
|
||||
class ReturnInsteadOfLeave(AbstractDetector): |
||||
""" |
||||
Check for cases where a return(a,b) is used in an assembly function that also returns two variables |
||||
""" |
||||
|
||||
ARGUMENT = "return-leave" |
||||
HELP = "If a `return` is used instead of a `leave`." |
||||
IMPACT = DetectorClassification.HIGH |
||||
CONFIDENCE = DetectorClassification.MEDIUM |
||||
|
||||
WIKI = "https://github.com/crytic/slither/wiki/Detector-Documentation#incorrect-assembly-return" |
||||
|
||||
WIKI_TITLE = "Return instead of leave in assembly" |
||||
WIKI_DESCRIPTION = "Detect if a `return` is used where a `leave` should be used." |
||||
WIKI_EXPLOIT_SCENARIO = """ |
||||
```solidity |
||||
contract C { |
||||
function f() internal returns (uint a, uint b) { |
||||
assembly { |
||||
return (5, 6) |
||||
} |
||||
} |
||||
|
||||
} |
||||
``` |
||||
The function will halt the execution, instead of returning a two uint.""" |
||||
|
||||
WIKI_RECOMMENDATION = "Use the `leave` statement." |
||||
|
||||
def _check_function(self, f: Function) -> List[Output]: |
||||
results: List[Output] = [] |
||||
|
||||
for node in f.nodes: |
||||
for ir in node.irs: |
||||
if isinstance(ir, SolidityCall) and ir.function == SolidityFunction( |
||||
"return(uint256,uint256)" |
||||
): |
||||
info: DETECTOR_INFO = [f, " contains an incorrect call to return: ", node, "\n"] |
||||
json = self.generate_result(info) |
||||
|
||||
results.append(json) |
||||
return results |
||||
|
||||
def _detect(self) -> List[Output]: |
||||
results: List[Output] = [] |
||||
for c in self.contracts: |
||||
for f in c.functions_declared: |
||||
|
||||
if ( |
||||
len(f.returns) == 2 |
||||
and f.contains_assembly |
||||
and f.visibility not in ["public", "external"] |
||||
): |
||||
results += self._check_function(f) |
||||
|
||||
return results |
@ -0,0 +1,93 @@ |
||||
""" |
||||
Module detecting incorrect operator usage for exponentiation where bitwise xor '^' is used instead of '**' |
||||
""" |
||||
from typing import Tuple, List, Union |
||||
|
||||
from slither.core.cfg.node import Node |
||||
from slither.core.declarations import Contract, Function |
||||
from slither.detectors.abstract_detector import ( |
||||
AbstractDetector, |
||||
DetectorClassification, |
||||
DETECTOR_INFO, |
||||
) |
||||
from slither.slithir.operations import Binary, BinaryType, Operation |
||||
from slither.slithir.utils.utils import RVALUE |
||||
from slither.slithir.variables.constant import Constant |
||||
from slither.utils.output import Output |
||||
|
||||
|
||||
def _is_constant_candidate(var: Union[RVALUE, Function]) -> bool: |
||||
""" |
||||
Check if the variable is a constant. |
||||
Do not consider variable that are expressed with hexadecimal. |
||||
Something like 2^0xf is likely to be a correct bitwise operator |
||||
:param var: |
||||
:return: |
||||
""" |
||||
return isinstance(var, Constant) and not var.original_value.startswith("0x") |
||||
|
||||
|
||||
def _is_bitwise_xor_on_constant(ir: Operation) -> bool: |
||||
return ( |
||||
isinstance(ir, Binary) |
||||
and ir.type == BinaryType.CARET |
||||
and (_is_constant_candidate(ir.variable_left) or _is_constant_candidate(ir.variable_right)) |
||||
) |
||||
|
||||
|
||||
def _detect_incorrect_operator(contract: Contract) -> List[Tuple[Function, Node]]: |
||||
ret: List[Tuple[Function, Node]] = [] |
||||
f: Function |
||||
for f in contract.functions + contract.modifiers: # type:ignore |
||||
# Heuristic: look for binary expressions with ^ operator where at least one of the operands is a constant, and |
||||
# the constant is not in hex, because hex typically is used with bitwise xor and not exponentiation |
||||
nodes = [node for node in f.nodes for ir in node.irs if _is_bitwise_xor_on_constant(ir)] |
||||
for node in nodes: |
||||
ret.append((f, node)) |
||||
return ret |
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods |
||||
class IncorrectOperatorExponentiation(AbstractDetector): |
||||
""" |
||||
Incorrect operator usage of bitwise xor mistaking it for exponentiation |
||||
""" |
||||
|
||||
ARGUMENT = "incorrect-exp" |
||||
HELP = "Incorrect exponentiation" |
||||
IMPACT = DetectorClassification.HIGH |
||||
CONFIDENCE = DetectorClassification.MEDIUM |
||||
|
||||
WIKI = "https://github.com/crytic/slither/wiki/Detector-Documentation#incorrect-exponentiation" |
||||
|
||||
WIKI_TITLE = "Incorrect exponentiation" |
||||
WIKI_DESCRIPTION = "Detect use of bitwise `xor ^` instead of exponential `**`" |
||||
WIKI_EXPLOIT_SCENARIO = """ |
||||
```solidity |
||||
contract Bug{ |
||||
uint UINT_MAX = 2^256 - 1; |
||||
... |
||||
} |
||||
``` |
||||
Alice deploys a contract in which `UINT_MAX` incorrectly uses `^` operator instead of `**` for exponentiation""" |
||||
|
||||
WIKI_RECOMMENDATION = "Use the correct operator `**` for exponentiation." |
||||
|
||||
def _detect(self) -> List[Output]: |
||||
"""Detect the incorrect operator usage for exponentiation where bitwise xor ^ is used instead of ** |
||||
|
||||
Returns: |
||||
list: (function, node) |
||||
""" |
||||
results: List[Output] = [] |
||||
for c in self.compilation_unit.contracts_derived: |
||||
res = _detect_incorrect_operator(c) |
||||
for (func, node) in res: |
||||
info: DETECTOR_INFO = [ |
||||
func, |
||||
" has bitwise-xor operator ^ instead of the exponentiation operator **: \n", |
||||
] |
||||
info += ["\t - ", node, "\n"] |
||||
results.append(self.generate_result(info)) |
||||
|
||||
return results |
@ -0,0 +1,123 @@ |
||||
from typing import List |
||||
|
||||
from slither.core.cfg.node import Node |
||||
from slither.core.declarations import Contract |
||||
from slither.core.declarations.function import Function |
||||
from slither.core.solidity_types import Type |
||||
from slither.detectors.abstract_detector import ( |
||||
AbstractDetector, |
||||
DetectorClassification, |
||||
DETECTOR_INFO, |
||||
) |
||||
from slither.slithir.operations import LowLevelCall, HighLevelCall |
||||
from slither.analyses.data_dependency.data_dependency import is_tainted |
||||
from slither.utils.output import Output |
||||
|
||||
|
||||
class ReturnBomb(AbstractDetector): |
||||
|
||||
ARGUMENT = "return-bomb" |
||||
HELP = "A low level callee may consume all callers gas unexpectedly." |
||||
IMPACT = DetectorClassification.LOW |
||||
CONFIDENCE = DetectorClassification.MEDIUM |
||||
|
||||
WIKI = "https://github.com/crytic/slither/wiki/Detector-Documentation#return-bomb" |
||||
|
||||
WIKI_TITLE = "Return Bomb" |
||||
WIKI_DESCRIPTION = "A low level callee may consume all callers gas unexpectedly." |
||||
WIKI_EXPLOIT_SCENARIO = """ |
||||
```solidity |
||||
//Modified from https://github.com/nomad-xyz/ExcessivelySafeCall |
||||
contract BadGuy { |
||||
function youveActivateMyTrapCard() external pure returns (bytes memory) { |
||||
assembly{ |
||||
revert(0, 1000000) |
||||
} |
||||
} |
||||
} |
||||
|
||||
contract Mark { |
||||
function oops(address badGuy) public{ |
||||
bool success; |
||||
bytes memory ret; |
||||
|
||||
// Mark pays a lot of gas for this copy |
||||
//(success, ret) = badGuy.call{gas:10000}( |
||||
(success, ret) = badGuy.call( |
||||
abi.encodeWithSelector( |
||||
BadGuy.youveActivateMyTrapCard.selector |
||||
) |
||||
); |
||||
|
||||
// Mark may OOG here, preventing local state changes |
||||
//importantCleanup(); |
||||
} |
||||
} |
||||
|
||||
``` |
||||
After Mark calls BadGuy bytes are copied from returndata to memory, the memory expansion cost is paid. This means that when using a standard solidity call, the callee can "returnbomb" the caller, imposing an arbitrary gas cost. |
||||
Callee unexpectedly makes the caller OOG. |
||||
""" |
||||
|
||||
WIKI_RECOMMENDATION = "Avoid unlimited implicit decoding of returndata." |
||||
|
||||
@staticmethod |
||||
def is_dynamic_type(ty: Type) -> bool: |
||||
# ty.is_dynamic ? |
||||
name = str(ty) |
||||
if "[]" in name or name in ("bytes", "string"): |
||||
return True |
||||
return False |
||||
|
||||
def get_nodes_for_function(self, function: Function, contract: Contract) -> List[Node]: |
||||
nodes = [] |
||||
for node in function.nodes: |
||||
for ir in node.irs: |
||||
if isinstance(ir, (HighLevelCall, LowLevelCall)): |
||||
if not is_tainted(ir.destination, contract): # type:ignore |
||||
# Only interested if the target address is controlled/tainted |
||||
continue |
||||
|
||||
if isinstance(ir, HighLevelCall) and isinstance(ir.function, Function): |
||||
# in normal highlevel calls return bombs are _possible_ |
||||
# if the return type is dynamic and the caller tries to copy and decode large data |
||||
has_dyn = False |
||||
if ir.function.return_type: |
||||
has_dyn = any( |
||||
self.is_dynamic_type(ty) for ty in ir.function.return_type |
||||
) |
||||
|
||||
if not has_dyn: |
||||
continue |
||||
|
||||
# If a gas budget was specified then the |
||||
# user may not know about the return bomb |
||||
if ir.call_gas is None: |
||||
# if a gas budget was NOT specified then the caller |
||||
# may already suspect the call may spend all gas? |
||||
continue |
||||
|
||||
nodes.append(node) |
||||
# TODO: check that there is some state change after the call |
||||
|
||||
return nodes |
||||
|
||||
def _detect(self) -> List[Output]: |
||||
results = [] |
||||
|
||||
for contract in self.compilation_unit.contracts: |
||||
for function in contract.functions_declared: |
||||
nodes = self.get_nodes_for_function(function, contract) |
||||
if nodes: |
||||
info: DETECTOR_INFO = [ |
||||
function, |
||||
" tries to limit the gas of an external call that controls implicit decoding\n", |
||||
] |
||||
|
||||
for node in sorted(nodes, key=lambda x: x.node_id): |
||||
info += ["\t", node, "\n"] |
||||
|
||||
res = self.generate_result(info) |
||||
results.append(res) |
||||
|
||||
return results |
@ -0,0 +1,69 @@ |
||||
from typing import List |
||||
from slither.detectors.abstract_detector import ( |
||||
AbstractDetector, |
||||
DetectorClassification, |
||||
DETECTOR_INFO, |
||||
) |
||||
from slither.slithir.operations import ( |
||||
Binary, |
||||
BinaryType, |
||||
) |
||||
|
||||
from slither.core.declarations import Function |
||||
from slither.utils.output import Output |
||||
|
||||
|
||||
class TautologicalCompare(AbstractDetector): |
||||
""" |
||||
Same variable comparison detector |
||||
""" |
||||
|
||||
ARGUMENT = "tautological-compare" |
||||
HELP = "Comparing a variable to itself always returns true or false, depending on comparison" |
||||
IMPACT = DetectorClassification.MEDIUM |
||||
CONFIDENCE = DetectorClassification.HIGH |
||||
|
||||
WIKI = "https://github.com/crytic/slither/wiki/Detector-Documentation#tautological-compare" |
||||
|
||||
WIKI_TITLE = "Tautological compare" |
||||
WIKI_DESCRIPTION = "A variable compared to itself is probably an error as it will always return `true` for `==`, `>=`, `<=` and always `false` for `<`, `>` and `!=`." |
||||
WIKI_EXPLOIT_SCENARIO = """ |
||||
```solidity |
||||
function check(uint a) external returns(bool){ |
||||
return (a >= a); |
||||
} |
||||
``` |
||||
`check` always return true.""" |
||||
|
||||
WIKI_RECOMMENDATION = "Remove comparison or compare to different value." |
||||
|
||||
def _check_function(self, f: Function) -> List[Output]: |
||||
affected_nodes = set() |
||||
for node in f.nodes: |
||||
for ir in node.irs: |
||||
if isinstance(ir, Binary): |
||||
if ir.type in [ |
||||
BinaryType.GREATER, |
||||
BinaryType.GREATER_EQUAL, |
||||
BinaryType.LESS, |
||||
BinaryType.LESS_EQUAL, |
||||
BinaryType.EQUAL, |
||||
BinaryType.NOT_EQUAL, |
||||
]: |
||||
if ir.variable_left == ir.variable_right: |
||||
affected_nodes.add(node) |
||||
|
||||
results = [] |
||||
for n in affected_nodes: |
||||
info: DETECTOR_INFO = [f, " compares a variable to itself:\n\t", n, "\n"] |
||||
res = self.generate_result(info) |
||||
results.append(res) |
||||
return results |
||||
|
||||
def _detect(self): |
||||
results = [] |
||||
|
||||
for f in self.compilation_unit.functions_and_modifiers: |
||||
results.extend(self._check_function(f)) |
||||
|
||||
return results |
@ -0,0 +1,9 @@ |
||||
Test.bad1() (tests/e2e/detectors/test_data/incorrect-exp/0.7.6/incorrect_exp.sol#9-12) has bitwise-xor operator ^ instead of the exponentiation operator **: |
||||
- UINT_MAX = 2 ^ 256 - 1 (tests/e2e/detectors/test_data/incorrect-exp/0.7.6/incorrect_exp.sol#10) |
||||
|
||||
Test.bad0(uint256) (tests/e2e/detectors/test_data/incorrect-exp/0.7.6/incorrect_exp.sol#5-7) has bitwise-xor operator ^ instead of the exponentiation operator **: |
||||
- a ^ 2 (tests/e2e/detectors/test_data/incorrect-exp/0.7.6/incorrect_exp.sol#6) |
||||
|
||||
Derived.slitherConstructorVariables() (tests/e2e/detectors/test_data/incorrect-exp/0.7.6/incorrect_exp.sol#30) has bitwise-xor operator ^ instead of the exponentiation operator **: |
||||
- my_var = 2 ^ 256 - 1 (tests/e2e/detectors/test_data/incorrect-exp/0.7.6/incorrect_exp.sol#3) |
||||
|
@ -0,0 +1,4 @@ |
||||
C.bad1() (tests/e2e/detectors/test_data/incorrect-return/0.8.10/incorrect_return.sol#21-24) calls C.indirect() (tests/e2e/detectors/test_data/incorrect-return/0.8.10/incorrect_return.sol#17-19) which halt the execution return(uint256,uint256)(5,6) (tests/e2e/detectors/test_data/incorrect-return/0.8.10/incorrect_return.sol#4) |
||||
|
||||
C.bad0() (tests/e2e/detectors/test_data/incorrect-return/0.8.10/incorrect_return.sol#8-11) calls C.internal_return() (tests/e2e/detectors/test_data/incorrect-return/0.8.10/incorrect_return.sol#2-6) which halt the execution return(uint256,uint256)(5,6) (tests/e2e/detectors/test_data/incorrect-return/0.8.10/incorrect_return.sol#4) |
||||
|
@ -0,0 +1,5 @@ |
||||
Mark.oops(address) (tests/e2e/detectors/test_data/return-bomb/0.8.20/return_bomb.sol#31-55) tries to limit the gas of an external call that controls implicit decoding |
||||
ret1 = BadGuy(badGuy).fbad{gas: 2000}() (tests/e2e/detectors/test_data/return-bomb/0.8.20/return_bomb.sol#42) |
||||
(x,str) = BadGuy(badGuy).fbad1{gas: 2000}() (tests/e2e/detectors/test_data/return-bomb/0.8.20/return_bomb.sol#44) |
||||
(success,ret) = badGuy.call{gas: 10000}(abi.encodeWithSelector(BadGuy.llbad.selector)) (tests/e2e/detectors/test_data/return-bomb/0.8.20/return_bomb.sol#47-51) |
||||
|
@ -0,0 +1,2 @@ |
||||
C.f() (tests/e2e/detectors/test_data/return-leave/0.8.10/incorrect_return.sol#3-7) contains an incorrect call to return: return(uint256,uint256)(5,6) (tests/e2e/detectors/test_data/return-leave/0.8.10/incorrect_return.sol#5) |
||||
|
@ -0,0 +1,3 @@ |
||||
A.check(uint256) (tests/e2e/detectors/test_data/tautological-compare/0.8.20/compare.sol#3-5) compares a variable to itself: |
||||
(a >= a) (tests/e2e/detectors/test_data/tautological-compare/0.8.20/compare.sol#4) |
||||
|
@ -0,0 +1,30 @@ |
||||
contract Test { |
||||
|
||||
uint my_var = 2 ^ 256-1; |
||||
|
||||
function bad0(uint a) internal returns (uint) { |
||||
return a^2; |
||||
} |
||||
|
||||
function bad1() internal returns (uint) { |
||||
uint UINT_MAX = 2^256-1; |
||||
return UINT_MAX; |
||||
} |
||||
|
||||
/* Correct exponentiation operator */ |
||||
function good0(uint a) internal returns (uint) { |
||||
return a**2; |
||||
} |
||||
|
||||
/* Neither operand is a constant */ |
||||
function good1(uint a) internal returns (uint) { |
||||
return a^a; |
||||
} |
||||
|
||||
/* The constant operand 0xff in hex typically means bitwise xor */ |
||||
function good2(uint a) internal returns (uint) { |
||||
return a^0xff; |
||||
} |
||||
} |
||||
|
||||
contract Derived is Test {} |
Binary file not shown.
@ -0,0 +1,36 @@ |
||||
contract C { |
||||
function internal_return() internal{ |
||||
assembly { |
||||
return (5, 6) |
||||
} |
||||
} |
||||
|
||||
function bad0() public returns (bool){ |
||||
internal_return(); |
||||
return true; |
||||
} |
||||
|
||||
function indirect2() internal { |
||||
internal_return(); |
||||
} |
||||
|
||||
function indirect() internal { |
||||
indirect2(); |
||||
} |
||||
|
||||
function bad1() public returns (bool){ |
||||
indirect(); |
||||
return true; |
||||
} |
||||
|
||||
function good0() public{ |
||||
// Dont report if there is no following operation |
||||
internal_return(); |
||||
} |
||||
|
||||
function good1() public returns (uint a, uint b){ |
||||
assembly { |
||||
return (5, 6) |
||||
} |
||||
} |
||||
} |
Binary file not shown.
@ -0,0 +1,57 @@ |
||||
contract BadGuy { |
||||
function llbad() external pure returns (bytes memory) { |
||||
assembly{ |
||||
revert(0, 1000000) |
||||
} |
||||
} |
||||
|
||||
function fgood() external payable returns (uint){ |
||||
assembly{ |
||||
return(0, 1000000) |
||||
} |
||||
} |
||||
|
||||
function fbad() external payable returns (uint[] memory){ |
||||
assembly{ |
||||
return(0, 1000000) |
||||
} |
||||
} |
||||
|
||||
function fbad1() external payable returns (uint, string memory){ |
||||
assembly{ |
||||
return(0, 1000000) |
||||
} |
||||
} |
||||
|
||||
|
||||
} |
||||
|
||||
contract Mark { |
||||
|
||||
function oops(address badGuy) public{ |
||||
bool success; |
||||
string memory str; |
||||
bytes memory ret; |
||||
uint x; |
||||
uint[] memory ret1; |
||||
|
||||
x = BadGuy(badGuy).fgood{gas:2000}(); |
||||
|
||||
ret1 = BadGuy(badGuy).fbad(); //good (no gas specified) |
||||
|
||||
ret1 = BadGuy(badGuy).fbad{gas:2000}(); |
||||
|
||||
(x, str) = BadGuy(badGuy).fbad1{gas:2000}(); |
||||
|
||||
// Mark pays a lot of gas for this copy 😬😬😬 |
||||
(success, ret) = badGuy.call{gas:10000}( |
||||
abi.encodeWithSelector( |
||||
BadGuy.llbad.selector |
||||
) |
||||
); |
||||
|
||||
// Mark may OOG here, preventing local state changes |
||||
//importantCleanup(); |
||||
} |
||||
} |
||||
|
Binary file not shown.
@ -0,0 +1,8 @@ |
||||
contract C { |
||||
|
||||
function f() internal returns (uint a, uint b){ |
||||
assembly { |
||||
return (5, 6) |
||||
} |
||||
} |
||||
} |
Binary file not shown.
@ -0,0 +1,6 @@ |
||||
|
||||
contract A{ |
||||
function check(uint a) external returns(bool){ |
||||
return (a >= a); |
||||
} |
||||
} |
Binary file not shown.
Loading…
Reference in new issue