snikrflow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snikrflow/__init__.py +0 -0
- snikrflow/analysis/__init__.py +0 -0
- snikrflow/analysis/backends/__init__.py +0 -0
- snikrflow/analysis/backends/angr/__init__.py +0 -0
- snikrflow/analysis/backends/angr/audit.py +145 -0
- snikrflow/analysis/backends/angr/callgraph.py +40 -0
- snikrflow/analysis/backends/angr/cfg.py +49 -0
- snikrflow/analysis/backends/angr/dataflow.py +205 -0
- snikrflow/analysis/backends/angr/functions.py +99 -0
- snikrflow/analysis/backends/angr/jni.py +45 -0
- snikrflow/analysis/backends/angr/reachability.py +84 -0
- snikrflow/analysis/backends/angr/sinks.py +74 -0
- snikrflow/analysis/backends/angr/symbolic.py +152 -0
- snikrflow/analysis/interfaces/__init__.py +0 -0
- snikrflow/analysis/interfaces/apk_audit.py +97 -0
- snikrflow/analysis/interfaces/audit.py +14 -0
- snikrflow/analysis/interfaces/callgraph.py +13 -0
- snikrflow/analysis/interfaces/cfg.py +9 -0
- snikrflow/analysis/interfaces/dataflow.py +31 -0
- snikrflow/analysis/interfaces/functions.py +20 -0
- snikrflow/analysis/interfaces/jni.py +23 -0
- snikrflow/analysis/interfaces/reachability.py +14 -0
- snikrflow/analysis/interfaces/sinks.py +22 -0
- snikrflow/analysis/interfaces/symbolic.py +30 -0
- snikrflow/android/__init__.py +0 -0
- snikrflow/android/apk.py +78 -0
- snikrflow/cli.py +590 -0
- snikrflow/core/__init__.py +0 -0
- snikrflow/core/exceptions.py +36 -0
- snikrflow/core/models.py +326 -0
- snikrflow/elf/__init__.py +0 -0
- snikrflow/elf/jni.py +54 -0
- snikrflow/elf/loader.py +105 -0
- snikrflow/elf/metadata.py +84 -0
- snikrflow/reporting/__init__.py +0 -0
- snikrflow/reporting/sarif.py +122 -0
- snikrflow/reporting/terminal.py +412 -0
- snikrflow/sinks/__init__.py +0 -0
- snikrflow/sinks/database.py +30 -0
- snikrflow/sinks/signatures.py +40 -0
- snikrflow/utils/__init__.py +0 -0
- snikrflow-0.1.0.dist-info/METADATA +240 -0
- snikrflow-0.1.0.dist-info/RECORD +47 -0
- snikrflow-0.1.0.dist-info/WHEEL +5 -0
- snikrflow-0.1.0.dist-info/entry_points.txt +2 -0
- snikrflow-0.1.0.dist-info/licenses/LICENSE +21 -0
- snikrflow-0.1.0.dist-info/top_level.txt +1 -0
snikrflow/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SnikrFlow angr backend -- audit orchestration.
|
|
3
|
+
|
|
4
|
+
Combines every prior analysis stage into one pipeline, per the doc's
|
|
5
|
+
performance philosophy: cheap triage on everything, expensive symbolic/
|
|
6
|
+
data-flow analysis only on the most promising candidates.
|
|
7
|
+
|
|
8
|
+
Pipeline:
|
|
9
|
+
1. Discover functions, JNI candidates, sinks (cheap -- one CFGFast run,
|
|
10
|
+
reused across all three thanks to the caching guard in functions.py).
|
|
11
|
+
2. For every (JNI entry, sink) pair, check STATIC call-graph
|
|
12
|
+
reachability (cheap -- no symbolic execution).
|
|
13
|
+
3. Rank the statically-reachable pairs by call-chain length (shorter =
|
|
14
|
+
more direct = higher priority) and take the top N for deep analysis.
|
|
15
|
+
4. Run data-flow analysis (symbolic, bounded by a short per-pair
|
|
16
|
+
timeout) ONLY on that top-N subset.
|
|
17
|
+
5. Score risk transparently (see _score_risk) and sort findings by risk.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from snikrflow.analysis.backends.angr.dataflow import check_data_flow
|
|
25
|
+
from snikrflow.analysis.backends.angr.functions import discover_functions
|
|
26
|
+
from snikrflow.analysis.backends.angr.jni import discover_jni_functions
|
|
27
|
+
from snikrflow.analysis.backends.angr.reachability import check_static_reachability
|
|
28
|
+
from snikrflow.analysis.backends.angr.sinks import discover_sinks
|
|
29
|
+
from snikrflow.core.models import (
|
|
30
|
+
AnalysisFinding,
|
|
31
|
+
AuditResult,
|
|
32
|
+
DataFlowStatus,
|
|
33
|
+
ReachabilityType,
|
|
34
|
+
RiskLevel,
|
|
35
|
+
)
|
|
36
|
+
from snikrflow.elf.loader import LoadedBinary
|
|
37
|
+
from snikrflow.elf.metadata import extract_binary_info
|
|
38
|
+
|
|
39
|
+
# Cap on how many statically-reachable pairs get the expensive data-flow
|
|
40
|
+
# treatment. Keeps `audit` bounded on binaries with many JNI x sink
|
|
41
|
+
# combinations -- deep analysis is a scarce resource, spent on the most
|
|
42
|
+
# direct (shortest) paths first.
|
|
43
|
+
MAX_DEEP_ANALYSIS_PAIRS = 5
|
|
44
|
+
DEFAULT_DEEP_TIMEOUT_SECONDS = 30
|
|
45
|
+
|
|
46
|
+
# Rough, documented, and DELIBERATELY simple severity weighting per sink
|
|
47
|
+
# category. This is a transparent prioritization heuristic, not a
|
|
48
|
+
# vulnerability database -- edit freely.
|
|
49
|
+
_CATEGORY_SEVERITY = {
|
|
50
|
+
"process_execution": 3,
|
|
51
|
+
"dynamic_loading": 3,
|
|
52
|
+
"memory": 2,
|
|
53
|
+
"networking": 2,
|
|
54
|
+
"filesystem": 1,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _score_risk(
|
|
59
|
+
reachability_type: ReachabilityType,
|
|
60
|
+
data_flow_status: Optional[DataFlowStatus],
|
|
61
|
+
category: str,
|
|
62
|
+
) -> RiskLevel:
|
|
63
|
+
severity = _CATEGORY_SEVERITY.get(category, 1)
|
|
64
|
+
|
|
65
|
+
if reachability_type != ReachabilityType.STATICALLY_REACHABLE:
|
|
66
|
+
return RiskLevel.LOW
|
|
67
|
+
|
|
68
|
+
if data_flow_status == DataFlowStatus.POSSIBLE:
|
|
69
|
+
return RiskLevel.CRITICAL if severity >= 3 else RiskLevel.HIGH
|
|
70
|
+
|
|
71
|
+
# Reachable via call graph, but data flow wasn't confirmed possible
|
|
72
|
+
# (either not checked, or checked and came back inconclusive).
|
|
73
|
+
return RiskLevel.HIGH if severity >= 3 else RiskLevel.MEDIUM
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_audit(binary: LoadedBinary, deep_timeout_seconds: int = DEFAULT_DEEP_TIMEOUT_SECONDS) -> AuditResult:
|
|
77
|
+
binary_info = extract_binary_info(binary)
|
|
78
|
+
|
|
79
|
+
all_functions = discover_functions(binary) # runs CFGFast once
|
|
80
|
+
jni_functions = discover_jni_functions(binary) # reuses it
|
|
81
|
+
sinks = discover_sinks(binary) # reuses it
|
|
82
|
+
|
|
83
|
+
# Step 1: cheap static reachability across every JNI x sink pair.
|
|
84
|
+
reachable_pairs = []
|
|
85
|
+
for jni_func in jni_functions:
|
|
86
|
+
for sink in sinks:
|
|
87
|
+
path = check_static_reachability(binary, jni_func.address, sink.address)
|
|
88
|
+
if path.reachable and path.reachability_type == ReachabilityType.STATICALLY_REACHABLE:
|
|
89
|
+
reachable_pairs.append((jni_func, sink, path))
|
|
90
|
+
|
|
91
|
+
# Step 2: prioritize shortest (most direct) call chains for deep analysis.
|
|
92
|
+
reachable_pairs.sort(key=lambda item: len(item[2].function_path))
|
|
93
|
+
deep_pairs = reachable_pairs[:MAX_DEEP_ANALYSIS_PAIRS]
|
|
94
|
+
deep_keys = {(j.address, s.address) for j, s, _ in deep_pairs}
|
|
95
|
+
|
|
96
|
+
# Step 3: build findings, running data-flow only on the selected subset.
|
|
97
|
+
findings: list[AnalysisFinding] = []
|
|
98
|
+
for jni_func, sink, path in reachable_pairs:
|
|
99
|
+
data_flow_status: Optional[DataFlowStatus] = None
|
|
100
|
+
confidence = path.confidence
|
|
101
|
+
notes: list[str] = [path.note] if path.note else []
|
|
102
|
+
|
|
103
|
+
if (jni_func.address, sink.address) in deep_keys:
|
|
104
|
+
try:
|
|
105
|
+
df_result = check_data_flow(
|
|
106
|
+
binary, jni_func.address, sink.address,
|
|
107
|
+
timeout_seconds=deep_timeout_seconds,
|
|
108
|
+
)
|
|
109
|
+
data_flow_status = df_result.status
|
|
110
|
+
confidence = df_result.confidence
|
|
111
|
+
if df_result.note:
|
|
112
|
+
notes.append(df_result.note)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
notes.append(f"Data-flow analysis failed: {e}")
|
|
115
|
+
|
|
116
|
+
risk = _score_risk(path.reachability_type, data_flow_status, sink.category)
|
|
117
|
+
|
|
118
|
+
findings.append(
|
|
119
|
+
AnalysisFinding(
|
|
120
|
+
title=f"JNI -> {sink.category}",
|
|
121
|
+
source_address=jni_func.address,
|
|
122
|
+
source_name=jni_func.name,
|
|
123
|
+
sink_address=sink.address,
|
|
124
|
+
sink_name=sink.name,
|
|
125
|
+
sink_category=sink.category,
|
|
126
|
+
reachability_type=path.reachability_type,
|
|
127
|
+
data_flow_status=data_flow_status,
|
|
128
|
+
risk_level=risk,
|
|
129
|
+
confidence=confidence,
|
|
130
|
+
notes=notes,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
risk_order = {RiskLevel.CRITICAL: 0, RiskLevel.HIGH: 1, RiskLevel.MEDIUM: 2, RiskLevel.LOW: 3}
|
|
135
|
+
findings.sort(key=lambda f: risk_order.get(f.risk_level, 4))
|
|
136
|
+
|
|
137
|
+
return AuditResult(
|
|
138
|
+
binary_info=binary_info,
|
|
139
|
+
function_count=len(all_functions),
|
|
140
|
+
jni_count=len(jni_functions),
|
|
141
|
+
sink_count=len(sinks),
|
|
142
|
+
interesting_path_count=len(reachable_pairs),
|
|
143
|
+
symbolic_analysis_count=len(deep_pairs),
|
|
144
|
+
findings=findings,
|
|
145
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SnikrFlow angr backend -- callers/callees lookup.
|
|
3
|
+
|
|
4
|
+
Reuses discover_functions() (runs CFGFast, builds kb.callgraph) so
|
|
5
|
+
callers/callees work off the same function set as `functions`/`jni`/
|
|
6
|
+
`sinks` -- no separate, possibly-inconsistent analysis pass.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from snikrflow.analysis.backends.angr.functions import discover_functions
|
|
12
|
+
from snikrflow.core.models import Function
|
|
13
|
+
from snikrflow.elf.loader import LoadedBinary
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _resolve(binary: LoadedBinary):
|
|
17
|
+
all_functions = discover_functions(binary)
|
|
18
|
+
by_addr = {f.address: f for f in all_functions}
|
|
19
|
+
callgraph = binary.project.kb.callgraph
|
|
20
|
+
return by_addr, callgraph
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_callers(binary: LoadedBinary, address: int) -> list[Function]:
|
|
24
|
+
"""Direct callers of the function at `address`. Indirect/unresolved calls are not included."""
|
|
25
|
+
by_addr, callgraph = _resolve(binary)
|
|
26
|
+
try:
|
|
27
|
+
caller_addrs = sorted(callgraph.predecessors(address))
|
|
28
|
+
except Exception:
|
|
29
|
+
caller_addrs = []
|
|
30
|
+
return [by_addr[a] for a in caller_addrs if a in by_addr]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def find_callees(binary: LoadedBinary, address: int) -> list[Function]:
|
|
34
|
+
"""Direct callees of the function at `address`. Indirect/unresolved calls are not included."""
|
|
35
|
+
by_addr, callgraph = _resolve(binary)
|
|
36
|
+
try:
|
|
37
|
+
callee_addrs = sorted(callgraph.successors(address))
|
|
38
|
+
except Exception:
|
|
39
|
+
callee_addrs = []
|
|
40
|
+
return [by_addr[a] for a in callee_addrs if a in by_addr]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SnikrFlow angr backend -- CFG analysis.
|
|
3
|
+
|
|
4
|
+
Supports CFGFast (default, scalable) and CFGEmulated (deeper, slower).
|
|
5
|
+
CFGEmulated has been deprecated/removed in some recent angr versions --
|
|
6
|
+
if it's unavailable here, we fail gracefully with a clear message rather
|
|
7
|
+
than crashing, per SnikrFlow's error-handling philosophy.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from snikrflow.core.exceptions import AnalysisIncompleteError
|
|
13
|
+
from snikrflow.core.models import CFGSummary
|
|
14
|
+
from snikrflow.elf.loader import LoadedBinary
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_cfg_summary(binary: LoadedBinary, mode: str = "fast") -> CFGSummary:
|
|
18
|
+
project = binary.project
|
|
19
|
+
|
|
20
|
+
if mode == "fast":
|
|
21
|
+
try:
|
|
22
|
+
cfg = project.analyses.CFGFast(normalize=True)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
raise AnalysisIncompleteError(f"CFGFast failed: {e}") from e
|
|
25
|
+
|
|
26
|
+
elif mode == "emulated":
|
|
27
|
+
try:
|
|
28
|
+
cfg = project.analyses.CFGEmulated(keep_state=False)
|
|
29
|
+
except AttributeError as e:
|
|
30
|
+
raise AnalysisIncompleteError(
|
|
31
|
+
"CFGEmulated is not available in this angr version. "
|
|
32
|
+
"Use --cfg-mode fast instead."
|
|
33
|
+
) from e
|
|
34
|
+
except Exception as e:
|
|
35
|
+
raise AnalysisIncompleteError(f"CFGEmulated failed: {e}") from e
|
|
36
|
+
|
|
37
|
+
else:
|
|
38
|
+
raise AnalysisIncompleteError(
|
|
39
|
+
f"Unknown CFG mode '{mode}'. Use 'fast' or 'emulated'."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
graph = cfg.graph
|
|
43
|
+
|
|
44
|
+
return CFGSummary(
|
|
45
|
+
function_count=len(project.kb.functions),
|
|
46
|
+
basic_block_count=graph.number_of_nodes(),
|
|
47
|
+
edge_count=graph.number_of_edges(),
|
|
48
|
+
mode=mode,
|
|
49
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SnikrFlow angr backend -- data flow (heuristic taint) analysis.
|
|
3
|
+
|
|
4
|
+
Reuses the same symbolic-buffer setup as symbolic.py's run_symbolic_search,
|
|
5
|
+
but instead of just checking whether the sink's address is reached, we
|
|
6
|
+
inspect the VALUE sitting in the sink's own first argument register at
|
|
7
|
+
the moment execution enters it. If that value's Claripy expression
|
|
8
|
+
contains our "sym_input" symbolic variable, the source's data possibly
|
|
9
|
+
flows into what the sink operates on -- not just "control flow got
|
|
10
|
+
there," but "the tainted value got there."
|
|
11
|
+
|
|
12
|
+
This is a HEURISTIC, single-argument-position check, not a sound full
|
|
13
|
+
taint analysis (per the doc's terminology rules). It can only ever
|
|
14
|
+
report POSSIBLE or INCONCLUSIVE, never CONFIRMED.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
import claripy
|
|
23
|
+
|
|
24
|
+
from snikrflow.analysis.backends.angr.symbolic import (
|
|
25
|
+
DEFAULT_INPUT_SIZE,
|
|
26
|
+
DEFAULT_MAX_ACTIVE_STATES,
|
|
27
|
+
DEFAULT_MAX_STEPS,
|
|
28
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
29
|
+
)
|
|
30
|
+
from snikrflow.core.exceptions import AnalysisIncompleteError
|
|
31
|
+
from snikrflow.core.models import Confidence, DataFlowResult, DataFlowStatus
|
|
32
|
+
from snikrflow.elf.loader import LoadedBinary
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_data_flow(
|
|
36
|
+
binary: LoadedBinary,
|
|
37
|
+
source: int,
|
|
38
|
+
sink: int,
|
|
39
|
+
avoid: Optional[list[int]] = None,
|
|
40
|
+
input_size: int = DEFAULT_INPUT_SIZE,
|
|
41
|
+
max_active_states: int = DEFAULT_MAX_ACTIVE_STATES,
|
|
42
|
+
max_steps: int = DEFAULT_MAX_STEPS,
|
|
43
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
44
|
+
) -> DataFlowResult:
|
|
45
|
+
project = binary.project
|
|
46
|
+
avoid = avoid or []
|
|
47
|
+
|
|
48
|
+
sym_input = claripy.BVS("sym_input", input_size * 8)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
state = project.factory.call_state(source, 0)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
raise AnalysisIncompleteError(
|
|
54
|
+
f"Failed to build a call state at {hex(source)}: {e}"
|
|
55
|
+
) from e
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
buf_addr = state.heap.allocate(input_size)
|
|
59
|
+
state.memory.store(buf_addr, sym_input)
|
|
60
|
+
cc = project.factory.cc()
|
|
61
|
+
arg_reg = cc.ARG_REGS[0]
|
|
62
|
+
setattr(state.regs, arg_reg, buf_addr)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
raise AnalysisIncompleteError(
|
|
65
|
+
f"Failed to set up the symbolic argument buffer: {e}"
|
|
66
|
+
) from e
|
|
67
|
+
|
|
68
|
+
simgr = project.factory.simgr(state)
|
|
69
|
+
|
|
70
|
+
start_time = time.time()
|
|
71
|
+
steps_taken = 0
|
|
72
|
+
stop_reason = "unknown"
|
|
73
|
+
|
|
74
|
+
while simgr.active:
|
|
75
|
+
if time.time() - start_time > timeout_seconds:
|
|
76
|
+
stop_reason = "timeout"
|
|
77
|
+
break
|
|
78
|
+
if steps_taken >= max_steps:
|
|
79
|
+
stop_reason = "max_steps_reached"
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
simgr.step()
|
|
84
|
+
except Exception:
|
|
85
|
+
stop_reason = "step_error"
|
|
86
|
+
break
|
|
87
|
+
steps_taken += 1
|
|
88
|
+
|
|
89
|
+
if avoid:
|
|
90
|
+
simgr.move(
|
|
91
|
+
from_stash="active", to_stash="avoided",
|
|
92
|
+
filter_func=lambda s: s.addr in avoid,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
simgr.move(
|
|
96
|
+
from_stash="active", to_stash="found",
|
|
97
|
+
filter_func=lambda s: s.addr == sink,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if simgr.found:
|
|
101
|
+
stop_reason = "sink_reached"
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
if len(simgr.active) > max_active_states:
|
|
105
|
+
simgr.active = simgr.active[:max_active_states]
|
|
106
|
+
|
|
107
|
+
if stop_reason == "unknown":
|
|
108
|
+
if simgr.found:
|
|
109
|
+
stop_reason = "sink_reached"
|
|
110
|
+
elif not simgr.active:
|
|
111
|
+
stop_reason = "no_active_states"
|
|
112
|
+
|
|
113
|
+
if not simgr.found:
|
|
114
|
+
return DataFlowResult(
|
|
115
|
+
source=source, sink=sink,
|
|
116
|
+
status=DataFlowStatus.INCONCLUSIVE,
|
|
117
|
+
confidence=Confidence.INCONCLUSIVE,
|
|
118
|
+
states_explored=steps_taken, stopped_reason=stop_reason,
|
|
119
|
+
note="Sink was not reached within the configured limits. This is inconclusive, not proof of safety.",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
found_state = simgr.found[0]
|
|
123
|
+
|
|
124
|
+
if not found_state.solver.satisfiable():
|
|
125
|
+
return DataFlowResult(
|
|
126
|
+
source=source, sink=sink,
|
|
127
|
+
status=DataFlowStatus.INCONCLUSIVE,
|
|
128
|
+
confidence=Confidence.LOW,
|
|
129
|
+
states_explored=steps_taken, stopped_reason=stop_reason,
|
|
130
|
+
note="Sink was reached, but the accumulated path constraints are UNSAT.",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Check the sink's first few argument registers for taint. Two checks
|
|
134
|
+
# per argument, since attacker data can show up either way:
|
|
135
|
+
# 1. The register VALUE itself is symbolic (integer/value args, or
|
|
136
|
+
# a pointer computed via symbolic arithmetic).
|
|
137
|
+
# 2. The register holds a concrete-ish pointer to memory that
|
|
138
|
+
# itself contains tainted bytes (buffer/string args -- e.g.
|
|
139
|
+
# memcpy's `src` -- where the taint lives in the pointed-to
|
|
140
|
+
# memory, not the pointer value itself).
|
|
141
|
+
# We check multiple argument positions (not just arg 1) because for
|
|
142
|
+
# many real sinks (memcpy, strcpy, sprintf...) the tainted data is
|
|
143
|
+
# arg 2, not arg 1.
|
|
144
|
+
try:
|
|
145
|
+
cc = project.factory.cc()
|
|
146
|
+
arg_regs = cc.ARG_REGS[:4]
|
|
147
|
+
except Exception as e:
|
|
148
|
+
return DataFlowResult(
|
|
149
|
+
source=source, sink=sink,
|
|
150
|
+
status=DataFlowStatus.INCONCLUSIVE,
|
|
151
|
+
confidence=Confidence.INCONCLUSIVE,
|
|
152
|
+
states_explored=steps_taken, stopped_reason=stop_reason,
|
|
153
|
+
note=f"Sink was reached but its calling convention could not be resolved: {e}",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
tainted = False
|
|
157
|
+
tainted_arg_index: Optional[int] = None
|
|
158
|
+
|
|
159
|
+
for idx, reg_name in enumerate(arg_regs):
|
|
160
|
+
try:
|
|
161
|
+
reg_value = getattr(found_state.regs, reg_name)
|
|
162
|
+
except Exception:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if any(v.startswith("sym_input") for v in reg_value.variables):
|
|
166
|
+
tainted, tainted_arg_index = True, idx
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
if not reg_value.symbolic:
|
|
170
|
+
try:
|
|
171
|
+
addr = found_state.solver.eval(reg_value)
|
|
172
|
+
probe = found_state.memory.load(addr, 16)
|
|
173
|
+
if any(v.startswith("sym_input") for v in probe.variables):
|
|
174
|
+
tainted, tainted_arg_index = True, idx
|
|
175
|
+
break
|
|
176
|
+
except Exception:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if tainted:
|
|
180
|
+
return DataFlowResult(
|
|
181
|
+
source=source, sink=sink,
|
|
182
|
+
status=DataFlowStatus.POSSIBLE,
|
|
183
|
+
confidence=Confidence.MEDIUM,
|
|
184
|
+
states_explored=steps_taken, stopped_reason=stop_reason,
|
|
185
|
+
note=(
|
|
186
|
+
f"Sink argument #{tainted_arg_index + 1} appears tainted by the "
|
|
187
|
+
"source's symbolic input (checked both the argument value "
|
|
188
|
+
"itself and, for pointer arguments, the memory it points "
|
|
189
|
+
"to). Heuristic result, not a sound full taint analysis."
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return DataFlowResult(
|
|
194
|
+
source=source, sink=sink,
|
|
195
|
+
status=DataFlowStatus.INCONCLUSIVE,
|
|
196
|
+
confidence=Confidence.LOW,
|
|
197
|
+
states_explored=steps_taken, stopped_reason=stop_reason,
|
|
198
|
+
note=(
|
|
199
|
+
"The sink was reached, but none of its first 4 arguments "
|
|
200
|
+
"(register value or pointed-to memory) appear derived from "
|
|
201
|
+
"the source's symbolic input. Note: a sink that takes no "
|
|
202
|
+
"arguments (a bare marker/flag function) will always show "
|
|
203
|
+
"this result -- there's nothing for it to receive."
|
|
204
|
+
),
|
|
205
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SnikrFlow angr backend -- function discovery.
|
|
3
|
+
|
|
4
|
+
This is one of the few modules allowed to touch angr's analysis objects
|
|
5
|
+
directly (project.analyses.CFGFast, project.kb.functions). It translates
|
|
6
|
+
angr's Function/CFG knowledge-base objects into SnikrFlow-owned Function
|
|
7
|
+
models before handing anything back up the stack.
|
|
8
|
+
|
|
9
|
+
We run CFGFast rather than CFGEmulated here -- it's the scalable option
|
|
10
|
+
suitable for an initial triage pass. Deeper/emulated CFG analysis is a
|
|
11
|
+
later milestone with its own --cfg-mode flag.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from snikrflow.core.exceptions import AnalysisIncompleteError
|
|
17
|
+
from snikrflow.core.models import Function, FunctionSource
|
|
18
|
+
from snikrflow.elf.loader import LoadedBinary
|
|
19
|
+
|
|
20
|
+
_JNI_PREFIXES = ("Java_",)
|
|
21
|
+
_JNI_EXACT = ("JNI_OnLoad", "JNI_OnUnload")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _looks_like_jni(name: str) -> bool:
|
|
25
|
+
if not name:
|
|
26
|
+
return False
|
|
27
|
+
if name in _JNI_EXACT:
|
|
28
|
+
return True
|
|
29
|
+
return name.startswith(_JNI_PREFIXES)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def discover_functions(binary: LoadedBinary) -> list[Function]:
|
|
33
|
+
project = binary.project
|
|
34
|
+
main_obj = project.loader.main_object
|
|
35
|
+
|
|
36
|
+
# Cross-reference lookup: address -> symbol, so we can tell whether an
|
|
37
|
+
# angr-discovered function also has a name/export/import status.
|
|
38
|
+
symbol_by_addr = {
|
|
39
|
+
sym.rebased_addr: sym for sym in main_obj.symbols if sym.rebased_addr
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
if len(project.kb.functions) == 0:
|
|
44
|
+
project.analyses.CFGFast(normalize=True)
|
|
45
|
+
# else: CFGFast has already populated kb.functions/kb.callgraph on
|
|
46
|
+
# this project (e.g. an earlier call in the same process, such as
|
|
47
|
+
# `audit` chaining functions -> jni -> sinks) -- reuse it instead
|
|
48
|
+
# of re-running the full analysis.
|
|
49
|
+
except Exception as e:
|
|
50
|
+
raise AnalysisIncompleteError(f"CFGFast analysis failed: {e}") from e
|
|
51
|
+
|
|
52
|
+
functions: list[Function] = []
|
|
53
|
+
|
|
54
|
+
for addr, kb_func in project.kb.functions.items():
|
|
55
|
+
# angr creates synthetic placeholder functions in its own internal
|
|
56
|
+
# "extern" address space to model imported functions we don't have
|
|
57
|
+
# the real code for (since auto_load_libs=False). These aren't
|
|
58
|
+
# real locations inside the analyzed binary, and they show up as
|
|
59
|
+
# noise (e.g. appearing to call themselves) if not filtered out.
|
|
60
|
+
if not main_obj.contains_addr(addr):
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
name = kb_func.name or f"sub_{addr:x}"
|
|
64
|
+
size = kb_func.size if kb_func.size else None
|
|
65
|
+
|
|
66
|
+
is_plt = bool(getattr(kb_func, "is_plt", False))
|
|
67
|
+
is_simprocedure = bool(getattr(kb_func, "is_simprocedure", False))
|
|
68
|
+
|
|
69
|
+
sym = symbol_by_addr.get(addr)
|
|
70
|
+
has_symbol = sym is not None
|
|
71
|
+
is_imported = bool(sym.is_import) if sym else is_simprocedure
|
|
72
|
+
is_exported = bool(getattr(sym, "is_export", False)) if sym else False
|
|
73
|
+
|
|
74
|
+
if is_plt:
|
|
75
|
+
source = FunctionSource.PLT_STUB
|
|
76
|
+
elif is_imported:
|
|
77
|
+
source = FunctionSource.IMPORT
|
|
78
|
+
elif has_symbol:
|
|
79
|
+
source = FunctionSource.SYMBOL_TABLE
|
|
80
|
+
else:
|
|
81
|
+
source = FunctionSource.ANGR_DISCOVERED
|
|
82
|
+
|
|
83
|
+
functions.append(
|
|
84
|
+
Function(
|
|
85
|
+
address=addr,
|
|
86
|
+
name=name,
|
|
87
|
+
size=size,
|
|
88
|
+
source=source,
|
|
89
|
+
has_symbol=has_symbol,
|
|
90
|
+
is_imported=is_imported,
|
|
91
|
+
is_exported=is_exported,
|
|
92
|
+
is_plt_stub=is_plt,
|
|
93
|
+
is_thunk=False, # thunk detection lands in a later milestone
|
|
94
|
+
is_jni_candidate=_looks_like_jni(name),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
functions.sort(key=lambda f: f.address)
|
|
99
|
+
return functions
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SnikrFlow angr backend -- JNI function discovery.
|
|
3
|
+
|
|
4
|
+
Reuses discover_functions() from functions.py (which runs CFGFast) rather
|
|
5
|
+
than re-running CFG analysis from scratch. CFGFast populates
|
|
6
|
+
project.kb.callgraph as a side effect, which we use here for callers/
|
|
7
|
+
callees -- so this stays cheap on top of an already-discovered function
|
|
8
|
+
set.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from snikrflow.analysis.backends.angr.functions import discover_functions
|
|
14
|
+
from snikrflow.core.models import Function
|
|
15
|
+
from snikrflow.elf.jni import demangle_jni_name
|
|
16
|
+
from snikrflow.elf.loader import LoadedBinary
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def discover_jni_functions(binary: LoadedBinary) -> list[Function]:
|
|
20
|
+
all_functions = discover_functions(binary) # also builds kb.callgraph
|
|
21
|
+
|
|
22
|
+
callgraph = binary.project.kb.callgraph
|
|
23
|
+
|
|
24
|
+
jni_functions: list[Function] = []
|
|
25
|
+
for f in all_functions:
|
|
26
|
+
if not f.is_jni_candidate:
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
java_class, java_method = demangle_jni_name(f.name)
|
|
30
|
+
f.likely_java_class = java_class
|
|
31
|
+
f.likely_java_method = java_method
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
f.callers = sorted(callgraph.predecessors(f.address))
|
|
35
|
+
except Exception:
|
|
36
|
+
f.callers = []
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
f.callees = sorted(callgraph.successors(f.address))
|
|
40
|
+
except Exception:
|
|
41
|
+
f.callees = []
|
|
42
|
+
|
|
43
|
+
jni_functions.append(f)
|
|
44
|
+
|
|
45
|
+
return jni_functions
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SnikrFlow angr backend -- static reachability.
|
|
3
|
+
|
|
4
|
+
Static reachability here means call-graph reachability: is there a chain
|
|
5
|
+
of DIRECT calls from source to sink? This only sees direct calls -- it
|
|
6
|
+
does NOT track indirect calls (function pointers, vtables, etc.), so a
|
|
7
|
+
"not reachable" result is never proof of safety, only "no direct call
|
|
8
|
+
chain found." Symbolic/data-flow reachability (later milestones) goes
|
|
9
|
+
further; runtime verification goes further still. These are never
|
|
10
|
+
treated as equivalent, per SnikrFlow's terminology rules.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import networkx as nx
|
|
16
|
+
|
|
17
|
+
from snikrflow.analysis.backends.angr.functions import discover_functions
|
|
18
|
+
from snikrflow.core.models import AnalysisPath, Confidence, ReachabilityType
|
|
19
|
+
from snikrflow.elf.loader import LoadedBinary
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_static_reachability(binary: LoadedBinary, source: int, sink: int) -> AnalysisPath:
|
|
23
|
+
all_functions = discover_functions(binary)
|
|
24
|
+
by_addr = {f.address: f for f in all_functions}
|
|
25
|
+
callgraph = binary.project.kb.callgraph
|
|
26
|
+
|
|
27
|
+
if source not in by_addr:
|
|
28
|
+
return AnalysisPath(
|
|
29
|
+
source=source, sink=sink, reachable=False,
|
|
30
|
+
reachability_type=ReachabilityType.INCONCLUSIVE,
|
|
31
|
+
confidence=Confidence.INCONCLUSIVE,
|
|
32
|
+
note=f"Source address {hex(source)} is not a known function start.",
|
|
33
|
+
)
|
|
34
|
+
if sink not in by_addr:
|
|
35
|
+
return AnalysisPath(
|
|
36
|
+
source=source, sink=sink, reachable=False,
|
|
37
|
+
reachability_type=ReachabilityType.INCONCLUSIVE,
|
|
38
|
+
confidence=Confidence.INCONCLUSIVE,
|
|
39
|
+
note=f"Sink address {hex(sink)} is not a known function start.",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if source == sink:
|
|
43
|
+
label = f"{by_addr[source].name} @ {hex(source)}"
|
|
44
|
+
return AnalysisPath(
|
|
45
|
+
source=source, sink=sink, reachable=True,
|
|
46
|
+
reachability_type=ReachabilityType.STATICALLY_REACHABLE,
|
|
47
|
+
function_path=[source], path_labels=[label],
|
|
48
|
+
confidence=Confidence.HIGH,
|
|
49
|
+
note="Source and sink are the same function.",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
path = nx.shortest_path(callgraph, source=source, target=sink)
|
|
54
|
+
except nx.NetworkXNoPath:
|
|
55
|
+
return AnalysisPath(
|
|
56
|
+
source=source, sink=sink, reachable=False,
|
|
57
|
+
reachability_type=ReachabilityType.NOT_REACHABLE,
|
|
58
|
+
confidence=Confidence.MEDIUM,
|
|
59
|
+
note=(
|
|
60
|
+
"No direct call-graph path found. This does NOT prove the "
|
|
61
|
+
"sink is unreachable -- indirect calls (function pointers, "
|
|
62
|
+
"vtables) aren't tracked by this analysis."
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
except nx.NodeNotFound:
|
|
66
|
+
return AnalysisPath(
|
|
67
|
+
source=source, sink=sink, reachable=False,
|
|
68
|
+
reachability_type=ReachabilityType.INCONCLUSIVE,
|
|
69
|
+
confidence=Confidence.INCONCLUSIVE,
|
|
70
|
+
note="Source or sink address not present in the call graph.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
labels = [
|
|
74
|
+
f"{by_addr[a].name if a in by_addr else f'sub_{a:x}'} @ {hex(a)}"
|
|
75
|
+
for a in path
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
return AnalysisPath(
|
|
79
|
+
source=source, sink=sink, reachable=True,
|
|
80
|
+
reachability_type=ReachabilityType.STATICALLY_REACHABLE,
|
|
81
|
+
function_path=path, path_labels=labels,
|
|
82
|
+
confidence=Confidence.MEDIUM,
|
|
83
|
+
note="Based on direct call-graph edges only; indirect/unresolved calls not tracked.",
|
|
84
|
+
)
|