Skip to content

Commit d80c2ff

Browse files
committed
Add configurable loop_analysis_max_depth and max_xrefs limits across config, CLI, and batch extractor
1 parent 75e71ae commit d80c2ff

8 files changed

Lines changed: 155 additions & 26 deletions

File tree

‎deep_extract/config.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ class AnalysisConfig:
4343
force_reanalyze: Force re-analysis even if already completed
4444
thunk_depth: Maximum depth for thunk resolution
4545
min_conf: Minimum confidence for function call validation
46+
loop_analysis_max_depth: Maximum recursion depth for loop analysis graph traversal
47+
max_xrefs: Maximum xrefs serialized per JSON xref field
4648
4749
# Computed file metadata
4850
file_hashes: MD5/SHA256 hashes of the input file
@@ -76,6 +78,8 @@ class AnalysisConfig:
7678
force_reanalyze: bool = False
7779
thunk_depth: Optional[int] = None
7880
min_conf: Optional[int] = None
81+
loop_analysis_max_depth: Optional[int] = None
82+
max_xrefs: Optional[int] = None
7983

8084
# Advanced analysis options
8185
use_interprocedural_analysis: bool = True # Enable deeper indirect call resolution
@@ -134,6 +138,16 @@ def __post_init__(self):
134138
raise ValueError(f"min_conf must be a number, got: {type(self.min_conf)}")
135139
if not 10 <= self.min_conf <= 100:
136140
raise ValueError(f"min_conf must be between 10 and 100, got: {self.min_conf}")
141+
142+
if self.loop_analysis_max_depth is not None:
143+
if not isinstance(self.loop_analysis_max_depth, int) or self.loop_analysis_max_depth < 1:
144+
raise ValueError(
145+
f"loop_analysis_max_depth must be a positive integer, got: {self.loop_analysis_max_depth}"
146+
)
147+
148+
if self.max_xrefs is not None:
149+
if not isinstance(self.max_xrefs, int) or self.max_xrefs < 1:
150+
raise ValueError(f"max_xrefs must be a positive integer, got: {self.max_xrefs}")
137151

138152
# Validate boolean flags
139153
bool_fields = [
@@ -237,6 +251,24 @@ def from_ida_args(cls, args: dict, input_file: str) -> 'AnalysisConfig':
237251
raise ValueError(f"min_conf must be 10-100, got: {min_conf}")
238252
except (TypeError, ValueError) as e:
239253
raise ValueError(f"Invalid min_conf: {e}")
254+
255+
loop_analysis_max_depth = args.get('loop_analysis_max_depth')
256+
if loop_analysis_max_depth is not None:
257+
try:
258+
loop_analysis_max_depth = int(loop_analysis_max_depth)
259+
if loop_analysis_max_depth < 1:
260+
raise ValueError(f"loop_analysis_max_depth must be positive, got: {loop_analysis_max_depth}")
261+
except (TypeError, ValueError) as e:
262+
raise ValueError(f"Invalid loop_analysis_max_depth: {e}")
263+
264+
max_xrefs = args.get('max_xrefs')
265+
if max_xrefs is not None:
266+
try:
267+
max_xrefs = int(max_xrefs)
268+
if max_xrefs < 1:
269+
raise ValueError(f"max_xrefs must be positive, got: {max_xrefs}")
270+
except (TypeError, ValueError) as e:
271+
raise ValueError(f"Invalid max_xrefs: {e}")
240272

241273
return cls(
242274
sqlite_db_path=sqlite_db_path,
@@ -258,6 +290,8 @@ def from_ida_args(cls, args: dict, input_file: str) -> 'AnalysisConfig':
258290
use_interprocedural_analysis=bool(args.get('use_interprocedural_analysis', True)),
259291
thunk_depth=thunk_depth,
260292
min_conf=min_conf,
293+
loop_analysis_max_depth=loop_analysis_max_depth,
294+
max_xrefs=max_xrefs,
261295
)
262296

263297
def to_dict(self) -> dict:
@@ -286,6 +320,8 @@ def to_dict(self) -> dict:
286320
'use_interprocedural_analysis': self.use_interprocedural_analysis,
287321
'thunk_depth': self.thunk_depth,
288322
'min_conf': self.min_conf,
323+
'loop_analysis_max_depth': self.loop_analysis_max_depth,
324+
'max_xrefs': self.max_xrefs,
289325
}
290326

291327
def to_analysis_flags_json(self) -> str:
@@ -318,5 +354,7 @@ def get_analysis_args_dict(self) -> dict:
318354
'force_reanalyze': self.force_reanalyze,
319355
'generate_cpp': self.generate_cpp,
320356
'generate_asm': self.generate_asm,
357+
'loop_analysis_max_depth': self.loop_analysis_max_depth,
358+
'max_xrefs': self.max_xrefs,
321359
}
322360

‎deep_extract/constants.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,13 @@
3939
# Jump Table Analysis Thresholds
4040
MIN_JUMP_TABLE_CONFIDENCE = 30.0 # Minimum confidence percentage to accept jump table detection
4141

42+
# Xref Serialization Configuration
43+
DEFAULT_MAX_XREFS = 500000 # Default maximum xrefs serialized per JSON xref field
44+
MIN_MAX_XREFS = 1000 # Minimum accepted EXTRACTOR_MAX_XREFS override
45+
4246
# Loop Analysis Configuration
4347
DEFAULT_LOOP_BLOCK_LIMIT = 5000 # Default maximum basic blocks before skipping loop analysis
48+
DEFAULT_LOOP_ANALYSIS_MAX_DEPTH = 5000 # Default recursion depth guard for loop graph traversal
4449
LOOP_ANALYSIS_WARNING_THRESHOLD = 500 # Warn when function exceeds this block count
4550

4651
# Batch Processing Configuration

‎deep_extract/json_safety.py‎

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from datetime import datetime
1313
from decimal import Decimal
1414

15+
from . import constants
1516
from .logging_utils import debug_print
1617

1718

@@ -537,27 +538,32 @@ def apply_field_limits(xref_list: List[Any], max_xrefs: int = 10000) -> List[Any
537538

538539

539540
# Convenience functions for common field types
540-
def _get_max_xrefs() -> int:
541+
def _get_max_xrefs(override: Optional[int] = None) -> int:
541542
"""Get the max xref limit from environment with sane fallback."""
542-
raw = os.environ.get("EXTRACTOR_MAX_XREFS", "")
543-
try:
544-
value = int(raw)
545-
except (TypeError, ValueError):
546-
value = 50000
547-
# Clamp to a reasonable range to avoid zero/negative or runaway values.
548-
if value < 1000:
549-
value = 1000
550-
if value > 500000:
551-
value = 500000
543+
if override is not None:
544+
value = int(override)
545+
else:
546+
raw = os.environ.get("EXTRACTOR_MAX_XREFS", "")
547+
try:
548+
value = int(raw)
549+
except (TypeError, ValueError):
550+
value = constants.DEFAULT_MAX_XREFS
551+
# Clamp only the lower bound; callers can opt into larger values when needed.
552+
if value < constants.MIN_MAX_XREFS:
553+
value = constants.MIN_MAX_XREFS
552554
return value
553555

554556

555-
def safe_serialize_xrefs(xrefs: List[Dict[str, Any]], field_name: str = "xrefs") -> str:
557+
def safe_serialize_xrefs(
558+
xrefs: List[Dict[str, Any]],
559+
field_name: str = "xrefs",
560+
max_xrefs: Optional[int] = None,
561+
) -> str:
556562
"""Safely serialize cross-reference list."""
557-
max_xrefs = _get_max_xrefs()
558-
if len(xrefs) > max_xrefs:
559-
debug_print(f"WARNING - Xref list exceeds limit ({len(xrefs)} > {max_xrefs}); truncation may occur")
560-
return to_json_safe(xrefs, max_list_items=max_xrefs, field_name=field_name)
563+
effective_max_xrefs = _get_max_xrefs(max_xrefs)
564+
if len(xrefs) > effective_max_xrefs:
565+
debug_print(f"WARNING - Xref list exceeds limit ({len(xrefs)} > {effective_max_xrefs}); truncation may occur")
566+
return to_json_safe(xrefs, max_list_items=effective_max_xrefs, field_name=field_name)
561567

562568

563569
def safe_serialize_strings(strings: List[str], field_name: str = "strings") -> str:

‎deep_extract/loop_analysis.py‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
"""
88

99
from typing import Dict, List, Tuple, Any, Optional
10+
import os
11+
import sys
1012
import ida_funcs
1113
import ida_bytes
1214
import ida_ua
@@ -24,6 +26,7 @@
2426
def extract_loop_analysis(function_ea: int,
2527
quantity_limit: bool = True,
2628
chunk_quantity: Optional[int] = None,
29+
max_depth: Optional[int] = None,
2730
config: Optional[Any] = None) -> Dict[str, Any]:
2831
"""
2932
Analyzes the control flow graph of a function to identify all loops.
@@ -35,6 +38,8 @@ def extract_loop_analysis(function_ea: int,
3538
:param quantity_limit: A flag to limit analysis on huge functions.
3639
:param chunk_quantity: The block count threshold to trigger the limit.
3740
If None, uses DEFAULT_LOOP_BLOCK_LIMIT from constants.
41+
:param max_depth: Recursion depth guard for graph traversals. If None, uses
42+
config, EXTRACTOR_LOOP_ANALYSIS_MAX_DEPTH, or the default.
3843
:param config: Optional AnalysisConfig object to get limit from config.
3944
:return: A dictionary containing the loops found and a count of them.
4045
"""
@@ -49,8 +54,23 @@ def extract_loop_analysis(function_ea: int,
4954
else:
5055
# Fall back to constant
5156
chunk_quantity = constants.DEFAULT_LOOP_BLOCK_LIMIT
57+
58+
if max_depth is None:
59+
if config and getattr(config, 'loop_analysis_max_depth', None) is not None:
60+
max_depth = config.loop_analysis_max_depth
61+
else:
62+
raw_max_depth = os.environ.get("EXTRACTOR_LOOP_ANALYSIS_MAX_DEPTH", "")
63+
try:
64+
max_depth = int(raw_max_depth) if raw_max_depth else constants.DEFAULT_LOOP_ANALYSIS_MAX_DEPTH
65+
except (TypeError, ValueError):
66+
max_depth = constants.DEFAULT_LOOP_ANALYSIS_MAX_DEPTH
67+
max_depth = max(1, int(max_depth))
5268

5369
try:
70+
previous_recursion_limit = sys.getrecursionlimit()
71+
if previous_recursion_limit <= max_depth:
72+
sys.setrecursionlimit(max_depth + 100)
73+
5474
func = ida_funcs.get_func(function_ea)
5575
if not func:
5676
return {"error": "Function not found.", "loops": [], "loop_count": 0}
@@ -121,7 +141,12 @@ def extract_loop_analysis(function_ea: int,
121141
on_stack = [False] * n
122142
sccs = []
123143

124-
def strongconnect(v):
144+
def strongconnect(v, depth=0):
145+
if depth > max_depth:
146+
raise RecursionError(
147+
f"loop analysis max depth exceeded ({max_depth}); "
148+
"set EXTRACTOR_LOOP_ANALYSIS_MAX_DEPTH or --loop-analysis-max-depth to increase"
149+
)
125150
indices[v] = index_counter[0]
126151
lowlinks[v] = index_counter[0]
127152
index_counter[0] += 1
@@ -130,7 +155,7 @@ def strongconnect(v):
130155

131156
for w in successors[v]:
132157
if indices[w] == -1:
133-
strongconnect(w)
158+
strongconnect(w, depth + 1)
134159
lowlinks[v] = min(lowlinks[v], lowlinks[w])
135160
elif on_stack[w]:
136161
lowlinks[v] = min(lowlinks[v], indices[w])
@@ -223,15 +248,20 @@ def _count_back_edges(loop_nodes: set) -> int:
223248
stack_set = set()
224249
count = 0
225250

226-
def dfs(node_id):
251+
def dfs(node_id, depth=0):
227252
nonlocal count
253+
if depth > max_depth:
254+
raise RecursionError(
255+
f"loop analysis max depth exceeded ({max_depth}); "
256+
"set EXTRACTOR_LOOP_ANALYSIS_MAX_DEPTH or --loop-analysis-max-depth to increase"
257+
)
228258
visited.add(node_id)
229259
stack_set.add(node_id)
230260
for succ_id in successors[node_id]:
231261
if succ_id not in loop_nodes:
232262
continue
233263
if succ_id not in visited:
234-
dfs(succ_id)
264+
dfs(succ_id, depth + 1)
235265
elif succ_id in stack_set:
236266
count += 1
237267
stack_set.remove(node_id)
@@ -332,4 +362,7 @@ def dfs(node_id):
332362
except Exception as e:
333363
debug_print(f"ERROR - [Loop Analysis] for 0x{function_ea:X}: {str(e)}")
334364
return {"error": str(e), "loops": [], "loop_count": 0}
365+
finally:
366+
if 'previous_recursion_limit' in locals() and sys.getrecursionlimit() != previous_recursion_limit:
367+
sys.setrecursionlimit(previous_recursion_limit)
335368

‎deep_extract/pe_context_extractor.py‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,8 @@ def parse_arguments() -> Optional[dict]:
775775
parser.add_argument("--asm-output-dir", type=str, default=None, help="Directory for ASM output files (defaults to same as cpp-output-dir or extracted_raw_code/ next to db)")
776776
parser.add_argument("--thunk-depth", type=int, default=None)
777777
parser.add_argument("--min-call-conf", type=float, default=None)
778+
parser.add_argument("--loop-analysis-max-depth", type=int, default=None)
779+
parser.add_argument("--max-xrefs", type=int, default=None)
778780

779781
# Process argv from IDA
780782
# idc.ARGV contains all arguments including script name
@@ -1272,7 +1274,10 @@ def _process_single_function(ea: int, string_map: Dict[int, Any], has_decompiler
12721274
if options.get('analyze_loops') and extractor_core.HAS_LOOP_DETECTOR:
12731275
loop_start = time.perf_counter()
12741276
try:
1275-
loop_analysis = extractor_core.extract_loop_analysis(ea)
1277+
loop_analysis = extractor_core.extract_loop_analysis(
1278+
ea,
1279+
max_depth=options.get('loop_analysis_max_depth'),
1280+
)
12761281
except Exception as e:
12771282
loop_analysis = None
12781283
analysis_errors.append({"stage": "loop_analysis", "error": str(e)})
@@ -1334,10 +1339,18 @@ def _process_single_function(ea: int, string_map: Dict[int, Any], has_decompiler
13341339
'function_name': extractor_core.get_raw_function_name(ea),
13351340
'assembly_code': '\n'.join(assembly_lines),
13361341
'decompiled_code': decompiled_code,
1337-
'inbound_xrefs': json_safety.safe_serialize_xrefs(xref_data["inbound_xrefs"], "inbound_xrefs"),
1338-
'outbound_xrefs': json_safety.safe_serialize_xrefs(xref_data["outbound_xrefs"], "outbound_xrefs"),
1339-
'simple_inbound_xrefs': json_safety.safe_serialize_xrefs(simple_inbound, "simple_inbound_xrefs"),
1340-
'simple_outbound_xrefs': json_safety.safe_serialize_xrefs(simple_outbound, "simple_outbound_xrefs"),
1342+
'inbound_xrefs': json_safety.safe_serialize_xrefs(
1343+
xref_data["inbound_xrefs"], "inbound_xrefs", max_xrefs=options.get('max_xrefs')
1344+
),
1345+
'outbound_xrefs': json_safety.safe_serialize_xrefs(
1346+
xref_data["outbound_xrefs"], "outbound_xrefs", max_xrefs=options.get('max_xrefs')
1347+
),
1348+
'simple_inbound_xrefs': json_safety.safe_serialize_xrefs(
1349+
simple_inbound, "simple_inbound_xrefs", max_xrefs=options.get('max_xrefs')
1350+
),
1351+
'simple_outbound_xrefs': json_safety.safe_serialize_xrefs(
1352+
simple_outbound, "simple_outbound_xrefs", max_xrefs=options.get('max_xrefs')
1353+
),
13411354
'vtable_contexts': json_safety.safe_serialize_vtable_contexts(xref_data["vtable_contexts"], "vtable_contexts"),
13421355
'global_var_accesses': json_safety.to_json_safe(global_accesses, max_list_items=1000, field_name="global_var_accesses"),
13431356
'loop_analysis': json_safety.safe_serialize_loop_analysis(loop_analysis, "loop_analysis"),
@@ -1368,6 +1381,8 @@ def extract_all_functions(sqlite_db_path, hashes, imports_json, exports_json, en
13681381
'extract_stack_frame': kwargs.get('extract_stack_frame', True),
13691382
'extract_globals': kwargs.get('extract_globals', True),
13701383
'analyze_loops': kwargs.get('analyze_loops', True),
1384+
'loop_analysis_max_depth': kwargs.get('loop_analysis_max_depth'),
1385+
'max_xrefs': kwargs.get('max_xrefs'),
13711386
}
13721387

13731388
profile_enabled = get_log_level() in ("TRACE", "DEBUG")

‎headless_batch_extractor.ps1‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@
3434
.PARAMETER TimeoutHours
3535
Maximum number of hours each IDA process may run before being terminated (default: 6).
3636
37+
.PARAMETER LoopAnalysisMaxDepth
38+
Maximum recursion depth for loop analysis graph traversal. Overrides the extractor default.
39+
40+
.PARAMETER MaxXrefs
41+
Maximum xrefs serialized per JSON xref field. Overrides the extractor default.
42+
3743
.PARAMETER Help
3844
Display detailed help information with usage examples.
3945
@@ -120,6 +126,12 @@ param(
120126
[ValidateRange(1, [int]::MaxValue)]
121127
[int]$TimeoutHours = 6,
122128

129+
[Parameter(HelpMessage = "Maximum recursion depth for loop analysis graph traversal (0 = extractor default)")]
130+
[int]$LoopAnalysisMaxDepth = 0,
131+
132+
[Parameter(HelpMessage = "Maximum xrefs serialized per JSON xref field (0 = extractor default)")]
133+
[int]$MaxXrefs = 0,
134+
123135
# Display help
124136
[Parameter(ParameterSetName = 'Help', HelpMessage = "Display detailed help information")]
125137
[Alias('h', '?')]
@@ -194,6 +206,8 @@ function Show-Help {
194206
Write-Host " -IdaPath <path> Path to IDA Pro executable (auto-detected if not specified)"
195207
Write-Host " -MaxConcurrentProcesses Number of parallel IDA processes (default: 4)"
196208
Write-Host " -TimeoutHours <hours> Per-process timeout before termination (default: 6)"
209+
Write-Host " -LoopAnalysisMaxDepth Loop analysis recursion depth guard (extractor default if omitted)"
210+
Write-Host " -MaxXrefs <count> Maximum xrefs serialized per JSON xref field (extractor default if omitted)"
197211
Write-Host " -Help Display this help message"
198212
Write-Host ""
199213
Write-Host "ANALYSIS FLAGS (disable specific features):" -ForegroundColor Yellow
@@ -248,6 +262,12 @@ function Show-Help {
248262
Write-Host " # Allow very large modules to run longer before timeout" -ForegroundColor Gray
249263
Write-Host " .\headless_batch_extractor.ps1 -ExtractDirRecursive 'C:\Binaries' -StorageDir 'C:\Analysis' -TimeoutHours 50"
250264
Write-Host ""
265+
Write-Host " # Increase loop analysis depth for complex CFGs" -ForegroundColor Gray
266+
Write-Host " .\headless_batch_extractor.ps1 -ExtractDirRecursive 'C:\Binaries' -StorageDir 'C:\Analysis' -LoopAnalysisMaxDepth 10000"
267+
Write-Host ""
268+
Write-Host " # Increase xref serialization limit for dense modules" -ForegroundColor Gray
269+
Write-Host " .\headless_batch_extractor.ps1 -ExtractDirRecursive 'C:\Binaries' -StorageDir 'C:\Analysis' -MaxXrefs 1000000"
270+
Write-Host ""
251271
Write-Host " # Skip string extraction to reduce analysis time" -ForegroundColor Gray
252272
Write-Host " .\headless_batch_extractor.ps1 -ExtractDir 'C:\Binaries' -StorageDir 'C:\Analysis' -NoExtractStrings"
253273
Write-Host ""
@@ -1262,6 +1282,14 @@ function Start-IDAProcesses {
12621282
if (-not $analysisFlags.extract_pe_metadata) { $idaScriptArgs.Add("--no-pe-metadata") }
12631283
if (-not $analysisFlags.extract_advanced_pe) { $idaScriptArgs.Add("--no-advanced-pe") }
12641284
if (-not $analysisFlags.extract_runtime_info) { $idaScriptArgs.Add("--no-runtime-info") }
1285+
if ($analysisFlags.loop_analysis_max_depth -and $analysisFlags.loop_analysis_max_depth -gt 0) {
1286+
$idaScriptArgs.Add("--loop-analysis-max-depth")
1287+
$idaScriptArgs.Add([string]$analysisFlags.loop_analysis_max_depth)
1288+
}
1289+
if ($analysisFlags.max_xrefs -and $analysisFlags.max_xrefs -gt 0) {
1290+
$idaScriptArgs.Add("--max-xrefs")
1291+
$idaScriptArgs.Add([string]$analysisFlags.max_xrefs)
1292+
}
12651293
if ($analysisFlags.force_reanalyze) { $idaScriptArgs.Add("--force-reanalyze") }
12661294
if ($analysisFlags.generate_cpp) {
12671295
$idaScriptArgs.Add("--generate-cpp")
@@ -2095,6 +2123,8 @@ $analysisFlags = @{
20952123
extract_pe_metadata = -not $NoPeMetadata.IsPresent
20962124
extract_advanced_pe = -not $NoAdvancedPe.IsPresent
20972125
extract_runtime_info = -not $NoRuntimeInfo.IsPresent
2126+
loop_analysis_max_depth = $LoopAnalysisMaxDepth
2127+
max_xrefs = $MaxXrefs
20982128
force_reanalyze = $ForceReanalyze.IsPresent
20992129
generate_cpp = -not $NoGenerateCpp.IsPresent
21002130
generate_asm = $GenerateAsm.IsPresent

‎ida-plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"plugin": {
44
"name": "DeepExtract",
55
"entryPoint": "main.py",
6-
"version": "0.9.12",
6+
"version": "0.9.13",
77
"idaVersions": [
88
"9.0",
99
"9.1",

0 commit comments

Comments
 (0)