应用安全 --- IDAPython脚本 之 函数调用链可视化
调用链可视化

ida脚本如下,优化的方向是提前去除所有的无关函数不进行计算,将去除杂质的节点独立计算每个调用链分开展示。应该将调用多的函数在中心展示,越往外围越是调用数越少
需要分析准确性
# -*- coding: utf-8 -*- """ IDA Call Graph — 全函数调用链可视化(业务/非业务/未知 三色分类) 新增:调用链CSV导出(节点函数、调用者、被调用者) """ import ida_funcs import ida_xref import ida_name import ida_idaapi import ida_entry import ida_segment import ida_nalt import idc import idautils import json import os import math import random import re import subprocess import csv from collections import defaultdict, deque # ===================================================== # 非业务函数识别规则库 # ===================================================== _SYS_EXACT = { # C 标准 I/O "printf", "fprintf", "sprintf", "snprintf", "vprintf", "vfprintf", "vsprintf", "vsnprintf", "puts", "fputs", "putchar", "putc", "fputc", "scanf", "fscanf", "sscanf", "vscanf", "vfscanf", "vsscanf", "gets", "fgets", "getchar", "getc", "fgetc", "ungetc", # 内存管理 "malloc", "calloc", "realloc", "free", "aligned_alloc", "posix_memalign", "memcpy", "memmove", "memset", "memcmp", "memchr", # 字符串 "strlen", "strcpy", "strncpy", "strcat", "strncat", "strcmp", "strncmp", "strchr", "strrchr", "strstr", "strtok", "strtok_r", "strdup", "strndup", "strerror", "strerror_r", "strpbrk", "strspn", "strcspn", "strsep", # 数字转换 "atoi", "atol", "atoll", "atof", "strtol", "strtoul", "strtoll", "strtoull", "strtod", "strtof", "strtold", # 文件 I/O "fopen", "fclose", "fread", "fwrite", "fseek", "ftell", "rewind", "fflush", "feof", "ferror", "clearerr", "fileno", "fdopen", "freopen", # POSIX I/O "open", "close", "read", "write", "lseek", "dup", "dup2", "pipe", "fcntl", "ioctl", "select", "poll", "epoll_create", "epoll_ctl", "epoll_wait", # 进程控制 "exit", "abort", "_exit", "_Exit", "atexit", "__cxa_atexit", # 算法/工具 "qsort", "bsearch", "abs", "labs", "llabs", "div", "ldiv", "lldiv", "rand", "srand", "random", "srandom", # 时间 "time", "clock", "difftime", "mktime", "strftime", "localtime", "gmtime", "asctime", "ctime", "gettimeofday", "clock_gettime", "nanosleep", "usleep", "sleep", # 字符分类 "isalpha", "isdigit", "isalnum", "isspace", "isupper", "islower", "isprint", "ispunct", "iscntrl", "isxdigit", "isgraph", "toupper", "tolower", # 信号 "signal", "raise", "sigaction", "sigprocmask", "kill", "sigpending", "sigwait", # 环境 "getenv", "setenv", "unsetenv", "putenv", "system", # 调试/断言 "assert", "__assert_fail", "__assert_rtn", "__assert2", "perror", # 数学 "sin", "cos", "tan", "asin", "acos", "atan", "atan2", "sqrt", "pow", "exp", "log", "log2", "log10", "ceil", "floor", "trunc", "round", "fabs", "fmod", "sinf", "cosf", "tanf", "sqrtf", "powf", "expf", "logf", # 内存映射 "mmap", "munmap", "mprotect", "madvise", "mremap", "brk", "sbrk", # 进程/线程 "fork", "vfork", "execve", "execv", "execvp", "wait", "waitpid", "waitid", "getpid", "getppid", "getuid", "geteuid", "getgid", "getegid", # 文件系统 "chdir", "getcwd", "chmod", "chown", "stat", "fstat", "lstat", "access", "link", "unlink", "rename", "symlink", "readlink", "mkdir", "rmdir", "opendir", "readdir", "closedir", # 网络 "socket", "bind", "listen", "accept", "connect", "send", "recv", "sendto", "recvfrom", "setsockopt", "getsockopt", "getaddrinfo", "freeaddrinfo", "htons", "ntohs", "htonl", "ntohl", # pthreads "pthread_create", "pthread_join", "pthread_detach", "pthread_exit", "pthread_mutex_init", "pthread_mutex_lock", "pthread_mutex_unlock", "pthread_cond_wait", "pthread_cond_signal", "pthread_cond_broadcast", "sem_init", "sem_wait", "sem_post", # 动态链接 "dlopen", "dlclose", "dlsym", "dlerror", # 系统日志 "syslog", "openlog", "closelog", # Android 专属 "__android_log_print", "__android_log_vprint", "__system_property_get", # 其他 "syscall", "sysconf", # C++ operator new/delete "_Znwm", "_Znwj", "_Znam", "_Znaj", "_ZdlPv", "_ZdlPvm", "_ZdaPv", "_ZdaPvm", "operator new", "operator delete", "operator new[]", "operator delete[]", # C++ ABI "__cxa_allocate_exception", "__cxa_free_exception", "__cxa_throw", "__cxa_begin_catch", "__cxa_end_catch", "__cxa_rethrow", "__cxa_guard_acquire", "__cxa_guard_release", "__cxa_pure_virtual", "__cxa_finalize", "__cxa_demangle", "__gxx_personality_v0", "__dynamic_cast", # 编译器内建 "__stack_chk_fail", "__stack_chk_guard", "__udivdi3", "__divdi3", "__moddi3", "__libc_start_main", "__libc_csu_init", "_Unwind_Resume", "_Unwind_RaiseException", # 编译器生成帧函数 "frame_dummy", "register_tm_clones", "deregister_tm_clones", "_init", "_fini", "_start", # Windows CRT "GetProcAddress", "LoadLibraryA", "LoadLibraryW", "VirtualAlloc", "VirtualFree", "HeapAlloc", "HeapFree", "CreateFileA", "CreateFileW", "ReadFile", "WriteFile", "CloseHandle", "GetLastError", "SetLastError", "MultiByteToWideChar", "WideCharToMultiByte", "ExitProcess", "GetCurrentProcessId", "GetCurrentThreadId", } _SYS_EXACT_LOWER = {x.lower() for x in _SYS_EXACT} _SYS_PREFIXES = ( "_ZNSt6__ndk1", "_ZNKSt6__ndk1", "_ZNSt3__1", "_ZNKSt3__1", "_ZN7_JNIEnv", "_ZN8_JavaVM", "_ZN12_GLOBAL__N_1", "_ZNK12_GLOBAL__N_1", "_ZL", "_ZN9libunwind", "_ZNKSt", "_ZNSt", "_ZSt", "_ZNSs", "_ZNSo", "_ZNSi", "_Znw", "_Zna", "_Zdl", "_Zda", "_ZTI", "_ZTS", "_ZTV", "_ZTT", "_ZN10__cxxabiv1", "_ZN9__gnu_cxx", "_cxa", "_gxx", "_gcc", "_gnu", "_aeabi", "_unw", "libunwind", "emutls", "_libc", "_lll", "_pthread", "frame", "GLOBAL", "__stack_chk", "__memcpy_chk", "__memmove_chk", "__strcpy_chk", "__strncpy_chk", "__sprintf_chk", "__snprintf_chk", "__printf_chk", "__vprintf_chk", "Unwind", "__on_dlclose", "__cxa", "imp_", "__imp_", "j_", "nullsub_", "_scrt", "_acrt", "RTC_", "_security", "__android_log", "_ubsan", "_asan", "_msan", "_tsan", ) _SYS_PATTERNS = [ re.compile(r'^\??\$L[A-Z]'), re.compile(r'^x86_get_pc_thunk_'), re.compile(r'^\?\?main[Cc][Rr][Tt]'), re.compile(r'^\?\?security_init_cookie'), re.compile(r'^\?\?security_check_cookie'), re.compile(r'^\?\?RTC'), re.compile(r'^\?\?CxxFrameHandler'), re.compile(r'^\?\?CxxThrowException'), re.compile(r'^\?\?scrt_'), re.compile(r'^acrt'), re.compile(r'^__tcf\d'), re.compile(r'^tcf\d'), re.compile(r'^\.\(_?LCFI|cfi\)\d'), re.compile(r'^\?\?guard'), re.compile(r'^operator\s+new(?:\[\])?\s*(?:\(|$)'), re.compile(r'^operator\s+delete(?:\[\])?\s*(?:\(|$)'), re.compile(r'^std::'), re.compile(r'^__cxxabiv1::'), re.compile(r'^__gnu_cxx::'), re.compile(r'^unwind_phase\d'), re.compile(r'^emutls'), re.compile(r'^abort_message$'), re.compile(r'^\.\w'), re.compile(r'^\(anonymous namespace\)::'), re.compile(r'^itanium_demangle::'), re.compile(r'^libunwind::'), ] _SYS_SEGMENTS = { '.plt', '.plt.got', '.plt.sec', '.got', '.got.plt', '__stubs', '__stub_helper', '__la_symbol_ptr', '.idata', '.didat', '.init', '.fini', '.init_array', '.fini_array', '.preinit_array', '__mod_init_func', '__mod_term_func', 'UNDEF', 'extern', } _DEMANGLED_NON_BIZ_PREFIXES = ( "std::", "__cxxabiv1::", "__gnu_cxx::", "std::__ndk1::", "std::__1::", "operator new", "operator delete", "typeinfo for ", "vtable for ", "typeinfo name for ", "construction vtable", "VTT for ", "guard variable for ", "reference temporary for ", "_JNIEnv::", "_JavaVM::", "(anonymous namespace)::", "itanium_demangle::", "libunwind::", ) # ===================================================== # 业务入口点白名单 # ===================================================== def _is_real_business_entry(name): if not name: return False if name.startswith('Java_'): return True if name in ('JNI_OnLoad', 'JNI_OnUnload'): return True if name in ('main', '_main', 'WinMain', 'wWinMain', 'DllMain', 'wmain'): return True return False # ===================================================== # 名称识别函数 # ===================================================== def is_non_business_by_name(name): if not name: return False, "" if name.startswith("sub_") or name.startswith("nullsub_"): return False, "" name_lower = name.lower() if name_lower in _SYS_EXACT_LOWER: return True, "exact:{}".format(name) for prefix in _SYS_PREFIXES: if name.startswith(prefix): return True, "prefix:{}".format(prefix) for pat in _SYS_PATTERNS: if pat.match(name): return True, "regex:{}".format(pat.pattern) demangled = idc.demangle_name(name, idc.get_inf_attr(idc.INF_SHORT_DN)) if demangled: dem_lower = demangled.lower() for prefix in _DEMANGLED_NON_BIZ_PREFIXES: if dem_lower.startswith(prefix.lower()): return True, "demangled:{}".format(demangled[:60]) for pat in _SYS_PATTERNS: if pat.match(demangled): return True, "regex_demangled:{}".format(pat.pattern) return False, "" def is_system_function(ea): flags = idc.get_func_attr(ea, idc.FUNCATTR_FLAGS) if flags != idc.BADADDR: if flags & idc.FUNC_LIB: return True, "FUNC_LIB" if flags & idc.FUNC_THUNK: return True, "FUNC_THUNK" seg = ida_segment.getseg(ea) if seg: seg_name = ida_segment.get_segm_name(seg) if seg_name in _SYS_SEGMENTS: return True, "segment:{}".format(seg_name) if seg.type == ida_segment.SEG_XTRN: return True, "SEG_XTRN" if seg_name.startswith('.plt') or seg_name in ('__stubs', '__stub_helper'): return True, "plt_segment" name = ida_name.get_name(ea) if name: ok, reason = is_non_business_by_name(name) if ok: return True, reason return False, "" # ===================================================== # 函数分类 # ===================================================== def get_function_name(ea): name = ida_name.get_name(ea) if not name: name = "sub_{:X}".format(ea) return name def find_entry_points(): entries = set() for i in range(ida_entry.get_entry_qty()): ordinal = ida_entry.get_entry_ordinal(i) ea = ida_entry.get_entry(ordinal) if ea == ida_idaapi.BADADDR: continue func = ida_funcs.get_func(ea) if not func: continue ep_name = ida_name.get_name(ea) or "" if _is_real_business_entry(ep_name): entries.add(func.start_ea) for ep_name in ['main', '_main', 'WinMain', 'wWinMain', 'DllMain', 'JNI_OnLoad', 'JNI_OnUnload', 'wmain']: ea = ida_name.get_name_ea(ida_idaapi.BADADDR, ep_name) if ea != ida_idaapi.BADADDR: func = ida_funcs.get_func(ea) if func: entries.add(func.start_ea) return entries def classify_function(ea, entry_points): func = ida_funcs.get_func(ea) if not func: return "unknown" name = get_function_name(ea) if _is_real_business_entry(name): return "business" is_sys, _ = is_system_function(ea) if is_sys: return "non-business" if name.startswith("sub_") or name.startswith("nullsub_"): return "unknown" if ea in entry_points: return "business" if func.size() > 0: return "business" return "unknown" # ===================================================== # 传播规则:系统库函数调用的 unknown → non-business # ===================================================== def propagate_lib_callees(func_categories): print("[*] Propagating lib-callee rule...") call_map = defaultdict(list) all_funcs_set = set(func_categories.keys()) for func_ea in all_funcs_set: func = ida_funcs.get_func(func_ea) if not func: continue for head in idautils.FuncItems(func_ea): for xref in idautils.XrefsFrom(head, 0): if xref.type not in (ida_xref.fl_CF, ida_xref.fl_CN, ida_xref.fl_JF, ida_xref.fl_JN): continue target = xref.to target_func = ida_funcs.get_func(target) if target_func: target_ea = target_func.start_ea else: continue if target_ea == func_ea: continue if target_ea in all_funcs_set: call_map[func_ea].append(target_ea) propagated = set() queue = deque() for ea, cat in func_categories.items(): if cat == "non-business": queue.append(ea) visited_as_source = set() rounds = 0 while queue: rounds += 1 new_in_this_round = 0 batch = list(queue) queue.clear() for nb_ea in batch: if nb_ea in visited_as_source: continue visited_as_source.add(nb_ea) for callee_ea in call_map.get(nb_ea, []): if func_categories.get(callee_ea) == "unknown": func_categories[callee_ea] = "non-business" propagated.add(callee_ea) new_in_this_round += 1 if callee_ea not in visited_as_source: queue.append(callee_ea) if new_in_this_round == 0: break print("[*] Propagation done: {} rounds, {} promoted".format(rounds, len(propagated))) return func_categories, propagated, rounds def scan_and_classify_all(): all_funcs = list(idautils.Functions()) total = len(all_funcs) entry_points = find_entry_points() W = 88 print("\n" + "=" * W) print(" FUNCTION SCAN & CLASSIFICATION") print("=" * W) print(" Total functions: {}".format(total)) print(" Entry points found: {}".format(len(entry_points))) print("=" * W) func_categories = {} counts = {"business": 0, "non-business": 0, "unknown": 0} for func_ea in all_funcs: cat = classify_function(func_ea, entry_points) func_categories[func_ea] = cat counts[cat] += 1 print("\n [Before propagation]") print(" Business : {}".format(counts["business"])) print(" Non-business: {}".format(counts["non-business"])) print(" Unknown : {}".format(counts["unknown"])) func_categories, propagated_eas, prop_rounds = propagate_lib_callees(func_categories) new_counts = {"business": 0, "non-business": 0, "unknown": 0} for cat in func_categories.values(): new_counts[cat] += 1 print("\n [After propagation ({} rounds, +{} promoted)]".format( prop_rounds, len(propagated_eas))) print(" Business : {}".format(new_counts["business"])) print(" Non-business: {}".format(new_counts["non-business"])) print(" Unknown : {}".format(new_counts["unknown"])) print("=" * W) return func_categories, entry_points # ===================================================== # 调用图构建 # ===================================================== def build_full_call_graph(func_categories, entry_points): print("[*] Building full call graph...") nodes = {} edges = [] edge_set = set() all_funcs_set = set(func_categories.keys()) for idx, func_ea in enumerate(sorted(all_funcs_set)): if idx % 500 == 0 and idx > 0: print(" {}/{}".format(idx, len(all_funcs_set))) func = ida_funcs.get_func(func_ea) if not func: continue cat = func_categories.get(func_ea, "unknown") is_entry = func_ea in entry_points if func_ea not in nodes: nodes[func_ea] = { "name": get_function_name(func_ea), "category": cat, "is_entry": is_entry, "out": 0, "in": 0, "size": func.size(), } for head in idautils.FuncItems(func_ea): for xref in idautils.XrefsFrom(head, 0): if xref.type not in (ida_xref.fl_CF, ida_xref.fl_CN, ida_xref.fl_JF, ida_xref.fl_JN): continue target = xref.to target_func = ida_funcs.get_func(target) if xref.type in (ida_xref.fl_JF, ida_xref.fl_JN): if target_func and target_func.start_ea == func_ea: continue if target_func: target = target_func.start_ea elif not ida_name.get_name(target): continue if target == func_ea: continue if target not in nodes: t_func = ida_funcs.get_func(target) t_cat = func_categories.get(target, "unknown") nodes[target] = { "name": get_function_name(target), "category": t_cat, "is_entry": target in entry_points, "out": 0, "in": 0, "size": t_func.size() if t_func else 0, } key = (func_ea, target) if key not in edge_set: edge_set.add(key) edges.append({"from": func_ea, "to": target}) nodes[func_ea]["out"] += 1 nodes[target]["in"] += 1 print("[*] Graph: {} nodes, {} edges".format(len(nodes), len(edges))) return nodes, edges # ===================================================== # 连通分量 / 最长链 # ===================================================== def find_components(nodes, edges): adj = defaultdict(set) for e in edges: adj[e["from"]].add(e["to"]) adj[e["to"]].add(e["from"]) visited = set() components = [] for ea in nodes: if ea in visited: continue comp = [] queue = deque([ea]) visited.add(ea) while queue: cur = queue.popleft() comp.append(cur) for nb in adj[cur]: if nb not in visited and nb in nodes: visited.add(nb) queue.append(nb) components.append(comp) components.sort(key=lambda c: len(c)) return components def find_longest_chain(nodes, edges): adj = defaultdict(list) in_deg = defaultdict(int) all_nodes = set(nodes.keys()) for e in edges: if e["from"] in all_nodes and e["to"] in all_nodes: adj[e["from"]].append(e["to"]) in_deg[e["to"]] += 1 roots = [ea for ea in all_nodes if in_deg[ea] == 0] if not roots: roots = list(all_nodes) best_path = [] if len(all_nodes) <= 500: def dfs(node, path, visited): nonlocal best_path if len(path) > len(best_path): best_path = list(path) for nxt in adj[node]: if nxt not in visited: visited.add(nxt) path.append(nxt) dfs(nxt, path, visited) path.pop() visited.discard(nxt) if len(best_path) > len(all_nodes) * 0.8: return for root in roots: dfs(root, [root], {root}) else: topo_order = [] temp_in_deg = {ea: in_deg.get(ea, 0) for ea in all_nodes} queue = deque([ea for ea in all_nodes if temp_in_deg[ea] == 0]) while queue: node = queue.popleft() topo_order.append(node) for nxt in adj[node]: temp_in_deg[nxt] -= 1 if temp_in_deg[nxt] == 0: queue.append(nxt) if len(topo_order) == len(all_nodes): dist = {ea: 1 for ea in all_nodes} parent = {ea: None for ea in all_nodes} for node in topo_order: for nxt in adj[node]: if dist[node] + 1 > dist[nxt]: dist[nxt] = dist[node] + 1 parent[nxt] = node end_node = max(dist, key=dist.get) path = [] cur = end_node while cur is not None: path.append(cur) cur = parent[cur] best_path = list(reversed(path)) else: max_depth = min(200, len(all_nodes)) sorted_roots = sorted(roots, key=lambda x: -len(adj[x]))[:50] for root in sorted_roots: stack = [(root, [root], {root})] while stack: node, path, visited = stack.pop() if len(path) > len(best_path): best_path = list(path) if len(path) >= max_depth: continue for nxt in adj[node]: if nxt not in visited: stack.append((nxt, path + [nxt], visited | {nxt})) print("[*] Longest chain: {} nodes".format(len(best_path))) return best_path def enumerate_all_chains(nodes, edges, max_chains=50000): call_map = defaultdict(list) all_nodes = set(nodes.keys()) for e in edges: if e["from"] in all_nodes and e["to"] in all_nodes: call_map[e["from"]].append(e["to"]) in_deg = defaultdict(int) for e in edges: if e["from"] in all_nodes and e["to"] in all_nodes: in_deg[e["to"]] += 1 roots = [ea for ea in all_nodes if in_deg[ea] == 0] if not roots: roots = sorted(all_nodes) used_edges = set() chains = [] def dfs(node, path, visited): available = [ t for t in call_map.get(node, []) if (node, t) not in used_edges and t not in visited ] if not available: if len(path) >= 2: chains.append(list(path)) return for nxt in available: if len(chains) >= max_chains: return used_edges.add((node, nxt)) visited.add(nxt) path.append(nxt) dfs(nxt, path, visited) path.pop() visited.discard(nxt) for root in sorted(roots, key=lambda x: nodes[x]["name"]): if len(chains) >= max_chains: break dfs(root, [root], {root}) for ea in sorted(all_nodes, key=lambda x: nodes[x]["name"]): if len(chains) >= max_chains: break remaining = [t for t in call_map.get(ea, []) if (ea, t) not in used_edges] if not remaining: continue dfs(ea, [ea], {ea}) chains.sort(key=lambda c: -len(c)) print("[*] {} chains, {} unique edges".format(len(chains), len(used_edges))) return chains # ===================================================== # ★ 新增:调用链 CSV 导出 # ===================================================== def generate_callchain_csv(nodes, edges, output_path): """ 导出调用链为CSV格式。 CSV列说明: - node_address : 节点函数地址(十六进制) - node_name : 节点函数名称 - node_category : 节点函数分类(business/non-business/unknown) - node_category_cn: 节点函数分类(中文) - node_size_b : 节点函数代码大小(字节) - node_in_degree : 被调用次数(入度) - node_out_degree : 调用其他函数次数(出度) - caller_address : 调用者地址(无调用者则为空) - caller_name : 调用者函数名称 - caller_category : 调用者分类 - callee_address : 被调用者地址(无被调用者则为空) - callee_name : 被调用者函数名称 - callee_category : 被调用者分类 - edge_direction : 调用关系方向描述 """ CAT_CN = { "business": "业务函数", "non-business": "非业务函数", "unknown": "未知函数", } # 构建调用关系索引 # callers_of[ea] = [(caller_ea, caller_info), ...] # callees_of[ea] = [(callee_ea, callee_info), ...] callers_of = defaultdict(list) # ea -> [caller_ea, ...] callees_of = defaultdict(list) # ea -> [callee_ea, ...] for e in edges: f_ea = e["from"] t_ea = e["to"] if f_ea in nodes and t_ea in nodes: callees_of[f_ea].append(t_ea) callers_of[t_ea].append(f_ea) rows = [] for ea in sorted(nodes.keys()): info = nodes[ea] node_addr = "0x{:X}".format(ea) node_name = info["name"] node_cat = info.get("category", "unknown") node_cat_cn = CAT_CN.get(node_cat, node_cat) node_size = info.get("size", 0) node_in = info.get("in", 0) node_out = info.get("out", 0) caller_list = callers_of.get(ea, []) callee_list = callees_of.get(ea, []) # 若既无调用者也无被调用者(孤立节点),单独输出一行 if not caller_list and not callee_list: rows.append({ "node_address": node_addr, "node_name": node_name, "node_category": node_cat, "node_category_cn": node_cat_cn, "node_size_b": node_size, "node_in_degree": node_in, "node_out_degree": node_out, "caller_address": "", "caller_name": "", "caller_category": "", "callee_address": "", "callee_name": "", "callee_category": "", "edge_direction": "isolated", }) continue # 展开每条调用关系:对每条 caller→node 和 node→callee 各输出一行 # 若没有调用者,callee方向仍输出 # 若没有被调用者,caller方向仍输出 # 生成 (caller_ea_or_None, callee_ea_or_None) 组合 # 策略:以边为单位,每条边输出一行 # - 对于 caller→node 的边: # node = 当前节点, caller = 上游, callee = "" # - 对于 node→callee 的边: # node = 当前节点, caller = "", callee = 下游 for caller_ea in caller_list: c_info = nodes.get(caller_ea, {}) c_cat = c_info.get("category", "unknown") rows.append({ "node_address": node_addr, "node_name": node_name, "node_category": node_cat, "node_category_cn": node_cat_cn, "node_size_b": node_size, "node_in_degree": node_in, "node_out_degree": node_out, "caller_address": "0x{:X}".format(caller_ea), "caller_name": c_info.get("name", ""), "caller_category": c_cat, "callee_address": "", "callee_name": "", "callee_category": "", "edge_direction": "{} --> {}".format( c_info.get("name", "?"), node_name), }) for callee_ea in callee_list: t_info = nodes.get(callee_ea, {}) t_cat = t_info.get("category", "unknown") rows.append({ "node_address": node_addr, "node_name": node_name, "node_category": node_cat, "node_category_cn": node_cat_cn, "node_size_b": node_size, "node_in_degree": node_in, "node_out_degree": node_out, "caller_address": "", "caller_name": "", "caller_category": "", "callee_address": "0x{:X}".format(callee_ea), "callee_name": t_info.get("name", ""), "callee_category": t_cat, "edge_direction": "{} --> {}".format( node_name, t_info.get("name", "?")), }) # 写入CSV(UTF-8 BOM,Excel可直接打开) fieldnames = [ "node_address", "node_name", "node_category", "node_category_cn", "node_size_b", "node_in_degree", "node_out_degree", "caller_address", "caller_name", "caller_category", "callee_address", "callee_name", "callee_category", "edge_direction", ] with open(output_path, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) print("[+] CallChain CSV: {} ({} rows)".format(output_path, len(rows))) return rows def generate_edge_csv(nodes, edges, output_path): """ 以边为核心导出CSV(每条调用关系独立一行,更适合数据分析)。 CSV列: - edge_id : 边序号 - caller_address : 调用者地址 - caller_name : 调用者名称 - caller_category : 调用者分类 - caller_cat_cn : 调用者分类(中文) - caller_size_b : 调用者代码大小 - callee_address : 被调用者地址 - callee_name : 被调用者名称 - callee_category : 被调用者分类 - callee_cat_cn : 被调用者分类(中文) - callee_size_b : 被调用者代码大小 - edge_type : 调用类型(biz->biz / biz->sys / sys->sys 等) """ CAT_CN = { "business": "业务函数", "non-business": "非业务函数", "unknown": "未知函数", } CAT_SHORT = { "business": "B", "non-business": "S", "unknown": "?", } fieldnames = [ "edge_id", "caller_address", "caller_name", "caller_category", "caller_cat_cn", "caller_size_b", "callee_address", "callee_name", "callee_category", "callee_cat_cn", "callee_size_b", "edge_type", ] rows = [] for i, e in enumerate(edges, 1): f_ea = e["from"] t_ea = e["to"] fi = nodes.get(f_ea, {}) ti = nodes.get(t_ea, {}) f_cat = fi.get("category", "unknown") t_cat = ti.get("category", "unknown") edge_type = "{}->{}".format(CAT_SHORT.get(f_cat, "?"), CAT_SHORT.get(t_cat, "?")) rows.append({ "edge_id": i, "caller_address": "0x{:X}".format(f_ea), "caller_name": fi.get("name", ""), "caller_category": f_cat, "caller_cat_cn": CAT_CN.get(f_cat, f_cat), "caller_size_b": fi.get("size", 0), "callee_address": "0x{:X}".format(t_ea), "callee_name": ti.get("name", ""), "callee_category": t_cat, "callee_cat_cn": CAT_CN.get(t_cat, t_cat), "callee_size_b": ti.get("size", 0), "edge_type": edge_type, }) with open(output_path, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) print("[+] Edge CSV: {} ({} rows, {} edges)".format( output_path, len(rows), len(edges))) return rows def generate_chain_sequence_csv(nodes, edges, output_path, max_chains=10000): """ 枚举所有调用链路径,每条链展开为多行(序列化表示)。 CSV列: - chain_id : 链编号 - chain_length : 链总长度(节点数) - step : 在链中的位置(0=起点) - node_address : 当前节点地址 - node_name : 当前节点名称 - node_category : 当前节点分类 - prev_address : 前一节点地址(起点为空) - prev_name : 前一节点名称 - next_address : 后一节点地址(终点为空) - next_name : 后一节点名称 - chain_path : 完整链路径(箭头连接) """ print("[*] Enumerating chains for sequence CSV...") chains = enumerate_all_chains(nodes, edges, max_chains=max_chains) fieldnames = [ "chain_id", "chain_length", "step", "node_address", "node_name", "node_category", "prev_address", "prev_name", "next_address", "next_name", "chain_path", ] rows = [] for ci, chain in enumerate(chains, 1): path_str = " -> ".join(nodes[ea]["name"] for ea in chain if ea in nodes) for si, ea in enumerate(chain): if ea not in nodes: continue info = nodes[ea] prev_ea = chain[si - 1] if si > 0 else None next_ea = chain[si + 1] if si < len(chain) - 1 else None prev_info = nodes.get(prev_ea, {}) if prev_ea else {} next_info = nodes.get(next_ea, {}) if next_ea else {} rows.append({ "chain_id": ci, "chain_length": len(chain), "step": si, "node_address": "0x{:X}".format(ea), "node_name": info.get("name", ""), "node_category": info.get("category", "unknown"), "prev_address": "0x{:X}".format(prev_ea) if prev_ea else "", "prev_name": prev_info.get("name", ""), "next_address": "0x{:X}".format(next_ea) if next_ea else "", "next_name": next_info.get("name", ""), "chain_path": path_str, }) with open(output_path, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) print("[+] Chain Sequence CSV: {} ({} rows, {} chains)".format( output_path, len(rows), len(chains))) return rows # ===================================================== # 文本导出 # ===================================================== def generate_text(nodes, edges, components, longest_chain, output_path): bin_name = ida_nalt.get_root_filename() or "binary" print("[*] Enumerating chains...") chains = enumerate_all_chains(nodes, edges) biz = sum(1 for n in nodes.values() if n["category"] == "business") nb = sum(1 for n in nodes.values() if n["category"] == "non-business") unk = sum(1 for n in nodes.values() if n["category"] == "unknown") TAG = {"business": "[B]", "non-business": "[S]", "unknown": "[?]"} L = [ "# FULL FUNCTION CALL CHAINS", "# Binary: {}".format(bin_name), "# Nodes:{} Edges:{} Components:{}".format(len(nodes), len(edges), len(components)), "# Business:{} Non-business:{} Unknown:{}".format(biz, nb, unk), "# Chains:{} LongestChain:{}".format(len(chains), len(longest_chain)), "#", ] nw = len(str(len(chains))) if chains else 1 for ci, chain in enumerate(chains, 1): names = ["{}{}".format(TAG.get(nodes[ea]["category"], "[?]"), nodes[ea]["name"]) for ea in chain] L.append("#{:<{w}} [{:>3}] {}".format(ci, len(chain), " -> ".join(names), w=nw)) chained = {ea for chain in chains for ea in chain} isolated = sorted([ea for ea in nodes if ea not in chained], key=lambda x: nodes[x]["name"]) if isolated: L.append("#") L.append("# ISOLATED NODES:") for ii, ea in enumerate(isolated, len(chains) + 1): cat = nodes[ea]["category"] tag = TAG.get(cat, "[?]") L.append("#{:<{w}} [ 1] {}{}".format(ii, tag, nodes[ea]["name"], w=nw)) text = "\n".join(L) with open(output_path, 'w', encoding='utf-8') as f: f.write(text) print("[+] TXT: {}".format(output_path)) return text # ===================================================== # 力导向布局 # ===================================================== def _degree(ea, nodes): return nodes[ea]["in"] + nodes[ea]["out"] def _force_directed_layout(comp, nodes, call_map, reverse_map, iterations=300): n = len(comp) if n == 1: return {comp[0]: (0.0, 0.0)} comp_set = set(comp) center_ea = sorted(comp, key=lambda x: -_degree(x, nodes))[0] adj = defaultdict(set) for ea in comp: for t in call_map.get(ea, []): if t in comp_set: adj[ea].add(t); adj[t].add(ea) for t in reverse_map.get(ea, []): if t in comp_set: adj[ea].add(t); adj[t].add(ea) random.seed(42) radius = math.sqrt(n) * 80 pos = {center_ea: (0.0, 0.0)} for ea in comp: if ea == center_ea: continue a = random.uniform(0, 2 * math.pi) r = random.uniform(radius * 0.2, radius) pos[ea] = (r * math.cos(a), r * math.sin(a)) k_r = 8000.0 * max(1, math.sqrt(n)) k_a = 0.005; k_c = 0.002; damp = 0.9; dt = 1.0; md = 10.0 mass = {ea: 1.0 + _degree(ea, nodes) * 0.5 for ea in comp} vel = {ea: (0.0, 0.0) for ea in comp} ea_list = list(comp) for it in range(iterations): forces = {ea: (0.0, 0.0) for ea in comp} for i in range(n): ei = ea_list[i]; xi, yi = pos[ei]; fx, fy = forces[ei] for j in range(i + 1, n): ej = ea_list[j]; xj, yj = pos[ej] dx, dy = xi - xj, yi - yj dist = math.sqrt(dx*dx + dy*dy) if dist < md: dist = md dx, dy = random.uniform(-1, 1), random.uniform(-1, 1) d2 = math.sqrt(dx*dx+dy*dy) if d2: dx /= d2; dy /= d2 f = k_r / (dist*dist); ux, uy = dx/dist, dy/dist fx += ux*f; fy += uy*f fjx, fjy = forces[ej] forces[ej] = (fjx - ux*f, fjy - uy*f) forces[ei] = (fx, fy) for ei in comp: xi, yi = pos[ei]; fx, fy = forces[ei] for ej in adj[ei]: xj, yj = pos[ej]; dx, dy = xj-xi, yj-yi dist = math.sqrt(dx*dx+dy*dy) if dist < md: continue ideal = 120 + (_degree(ei, nodes)+_degree(ej, nodes))*5 f = k_a*(dist-ideal); fx += (dx/dist)*f; fy += (dy/dist)*f forces[ei] = (fx, fy) cx = sum(pos[ea][0] for ea in comp)/n cy = sum(pos[ea][1] for ea in comp)/n for ea in comp: fx, fy = forces[ea] forces[ea] = (fx+(cx-pos[ea][0])*k_c, fy+(cy-pos[ea][1])*k_c) max_move = 0 for ea in comp: if ea == center_ea: continue vx, vy = vel[ea]; fx, fy = forces[ea]; m = mass[ea] vx = (vx+fx/m*dt)*damp; vy = (vy+fy/m*dt)*damp sp = math.sqrt(vx*vx+vy*vy) ms = 50.0/(1+it*0.01) if sp > ms: vx = vx/sp*ms; vy = vy/sp*ms vel[ea] = (vx, vy) nx, ny = pos[ea][0]+vx*dt, pos[ea][1]+vy*dt move = abs(nx-pos[ea][0])+abs(ny-pos[ea][1]) if move > max_move: max_move = move pos[ea] = (nx, ny) if max_move < 0.1 and it > 50: break return pos def layout_components(components, nodes, edges): ea_to_pos = {} call_map = defaultdict(list) reverse_map = defaultdict(list) for e in edges: call_map[e["from"]].append(e["to"]) reverse_map[e["to"]].append(e["from"]) sorted_comps = sorted(components, key=lambda c: -len(c)) comp_results = [] for comp in sorted_comps: n = len(comp) if n == 1: pos = {comp[0]: (0.0, 0.0)} else: pos = _force_directed_layout( comp, nodes, call_map, reverse_map, min(500, max(100, n*3))) xs = [pos[ea][0] for ea in comp] ys = [pos[ea][1] for ea in comp] x0, y0 = min(xs), min(ys) pad = 80 w = (max(xs)-x0)+pad*2; h = (max(ys)-y0)+pad*2 norm = {ea: (pos[ea][0]-x0+pad, pos[ea][1]-y0+pad) for ea in comp} comp_results.append((comp, norm, w, h)) MAX_ROW = 6000; GAP = 300; cx = cy = 0.0; rh = 0.0 for comp, norm, w, h in comp_results: if cx > 0 and cx+w > MAX_ROW: cx = 0.0; cy += rh+GAP; rh = 0.0 for ea in comp: lx, ly = norm[ea] ea_to_pos[ea] = (cx+lx, cy+ly) cx += w+GAP if h > rh: rh = h print("[*] Layout: {} components".format(len(comp_results))) return ea_to_pos def compute_node_size(info): code_size = info.get("size", 0) out_deg = info.get("out", 0) base = 8 + min(math.log2(code_size+1)*4, 52) if code_size > 0 else 8 factor = min(0.6 + out_deg*0.04, 1.0) if out_deg > 0 else 0.6 return max(6, min(base * factor, 60)) # ===================================================== # HTML 可视化(新增 Chain CSV 按钮) # ===================================================== def generate_html(nodes, edges, components, txt_content, longest_chain, output_path): COLOR_MAP = { "business": {"bg": "#1565C0", "label": u"业务"}, "non-business": {"bg": "#E91E63", "label": u"非业务"}, "unknown": {"bg": "#9E9E9E", "label": u"未知"}, } print("[*] Layout...") ea_to_pos = layout_components(components, nodes, edges) ea_to_comp = {} comp_info = [] for ci, comp in enumerate(components): for ea in comp: ea_to_comp[ea] = ci comp_info.append({"id": ci, "size": len(comp)}) ea_to_id = {} vn, ve = [], [] adj_down, adj_up = defaultdict(list), defaultdict(list) for idx, (ea, info) in enumerate(sorted(nodes.items())): ea_to_id[ea] = idx cat = info.get("category", "unknown") c = COLOR_MAP.get(cat, COLOR_MAP["unknown"]) sz = compute_node_size(info) if info.get("is_entry", False): sz = max(sz, 30) short = info["name"][:19] + "..." if len(info["name"]) > 22 else info["name"] label = u"{}\n\u2191{} \u2193{}".format(short, info["in"], info["out"]) fc = "#FFF" if cat != "unknown" else "#333" px, py = ea_to_pos.get(ea, (0, 0)) ci = ea_to_comp.get(ea, -1) vn.append({ "id": idx, "label": label, "x": px, "y": py, "color": {"background": c["bg"], "border": c["bg"], "highlight": {"background": c["bg"], "border": c["bg"]}}, "shadow": {"enabled": True, "color": "rgba(0,0,0,0.1)", "size": 8, "x": 0, "y": 3}, "shape": "dot", "size": sz, "font": {"color": fc, "size": 10, "face": "Arial", "multi": True, "align": "center"}, "borderWidth": 0, "borderWidthSelected": 0, "category": cat, "isEntry": info.get("is_entry", False), "ea": "0x{:X}".format(ea), "fullName": info["name"], "outDeg": info["out"], "inDeg": info["in"], "totalDeg": info["in"] + info["out"], "funcSize": info["size"], "catLabel": c["label"], "bgColor": c["bg"], "ftColor": fc, "compId": ci, "compSize": len(components[ci]) if 0 <= ci < len(components) else 0, }) for i, e in enumerate(edges): if e["from"] in ea_to_id and e["to"] in ea_to_id: fid, tid = ea_to_id[e["from"]], ea_to_id[e["to"]] adj_down[fid].append(tid) adj_up[tid].append(fid) sc = nodes[e["from"]].get("category", "unknown") dc = nodes[e["to"]].get("category", "unknown") if sc == "business" and dc == "business": ec = "rgba(21,101,192,0.35)" elif sc == "non-business" or dc == "non-business": ec = "rgba(233,30,99,0.15)" else: ec = "rgba(80,80,80,0.15)" inv_nb = (sc == "non-business" or dc == "non-business") ve.append({ "id": i, "from": fid, "to": tid, "arrows": {"to": {"enabled": True, "scaleFactor": 0.5, "type": "arrow"}}, "color": {"color": ec, "highlight": "#C62828"}, "width": 1.0, "smooth": {"type": "continuous", "roundness": 0.3}, "selectionWidth": 2, "involvesNonBiz": inv_nb, }) adj_dd = {str(k): v for k, v in adj_down.items()} adj_ud = {str(k): v for k, v in adj_up.items()} txt_esc = json.dumps(txt_content, ensure_ascii=False) lc_ids = [ea_to_id[ea] for ea in longest_chain if ea in ea_to_id] lc_edge_ids = [] for i in range(len(lc_ids) - 1): for ei, e in enumerate(ve): if e["from"] == lc_ids[i] and e["to"] == lc_ids[i + 1]: lc_edge_ids.append(e["id"]); break biz_n = sum(1 for n in vn if n["category"] == "business") nb_n = sum(1 for n in vn if n["category"] == "non-business") unk_n = sum(1 for n in vn if n["category"] == "unknown") # ★ HTML模板(新增 "ChainCSV" 导出按钮及对应JS函数) page = r'''<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Call Graph — Business(Blue)/Non-Biz(Pink)/Unknown(Gray)</title> <script src="https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js"></script> <style> *{margin:0;padding:0;box-sizing:border-box} body{background:#FAFAFA;color:#333;font-family:'Segoe UI',Arial,sans-serif;overflow:hidden} #bar{position:fixed;top:0;left:0;right:0;height:44px;background:#fff; border-bottom:1px solid #E0E0E0;display:flex;align-items:center; padding:0 12px;gap:5px;z-index:100;box-shadow:0 1px 4px rgba(0,0,0,0.03);overflow-x:auto} .t{color:#1565C0;font-size:13px;font-weight:bold;white-space:nowrap} .s{color:#AAA;font-size:9px;white-space:nowrap} .tag{padding:1px 6px;border-radius:4px;font-size:8px;font-weight:bold;white-space:nowrap} .tag-biz{background:#E3F2FD;color:#1565C0} .tag-nb{background:#FCE4EC;color:#C2185B} .tag-unk{background:#F5F5F5;color:#757575} #search{background:#F0F0F0;color:#333;border:1px solid #DDD;padding:4px 10px; border-radius:6px;width:150px;font-size:10px;flex-shrink:0} #search:focus{border-color:#1565C0;outline:none;background:#fff} .b{background:#F0F0F0;color:#555;border:1px solid #DDD;padding:3px 9px; border-radius:6px;cursor:pointer;font-size:9px;white-space:nowrap; transition:all .12s;flex-shrink:0} .b:hover{background:#E3F2FD;border-color:#1565C0;color:#1565C0} .b.red{background:#C62828;border-color:#C62828;color:#fff} .b.grn{background:#2E7D32;border-color:#2E7D32;color:#fff} .b.grn:hover{background:#388E3C} .b.purple{background:#7B1FA2;border-color:#7B1FA2;color:#fff} .b.purple:hover{background:#9C27B0} .b.pink{background:#E91E63;border-color:#E91E63;color:#fff} .b.pink:hover{background:#F06292} .b.pink.active{background:#880E4F;border-color:#880E4F} .b.orange{background:#E65100;border-color:#E65100;color:#fff} .b.orange:hover{background:#F57C00} .b.teal{background:#00695C;border-color:#00695C;color:#fff} .b.teal:hover{background:#00897B} .sep{width:1px;height:20px;background:#E0E0E0;flex-shrink:0} #graph{position:fixed;top:44px;left:0;right:0;bottom:0;background:#FAFAFA;cursor:grab} #info{position:fixed;bottom:12px;left:12px;background:#fff;border:1px solid #E0E0E0; border-radius:10px;padding:10px 14px;max-width:360px;font-size:9px; z-index:99;display:none;line-height:1.8;box-shadow:0 3px 14px rgba(0,0,0,0.05)} #info .x{position:absolute;top:5px;right:8px;cursor:pointer;color:#ccc;font-size:14px} #info .x:hover{color:#C62828} #legend{position:fixed;top:52px;right:12px;background:rgba(255,255,255,0.97); border:1px solid #E0E0E0;border-radius:8px;padding:8px 12px;font-size:8px; z-index:99;line-height:2;box-shadow:0 2px 8px rgba(0,0,0,0.03)} .lr{display:flex;align-items:center;gap:6px} .ld{width:9px;height:9px;border-radius:50%} #sr{position:fixed;top:44px;left:220px;background:#fff;border:1px solid #E0E0E0; border-radius:0 0 8px 8px;max-height:240px;overflow-y:auto;width:240px; z-index:101;display:none;box-shadow:0 3px 12px rgba(0,0,0,0.04)} .si{padding:4px 9px;cursor:pointer;font-size:9px;border-bottom:1px solid #F5F5F5; white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .si:hover{background:#F5F8FF} .sa{color:#BBB;margin-left:3px} #back{position:fixed;bottom:12px;right:12px;z-index:99;display:none} #mode{position:fixed;top:48px;left:50%;transform:translateX(-50%); background:#C62828;color:#fff;padding:3px 12px; border-radius:0 0 6px 6px;font-size:9px;z-index:99;display:none} #stats{position:fixed;bottom:12px;left:50%;transform:translateX(-50%); background:rgba(255,255,255,0.95);border:1px solid #E0E0E0; border-radius:7px;padding:5px 12px;font-size:9px;z-index:99;color:#888;white-space:nowrap} /* Export Panel */ #expPanel{position:fixed;top:52px;right:160px;background:#fff; border:1px solid #E0E0E0;border-radius:8px;padding:12px 16px; min-width:320px;z-index:102;display:none; box-shadow:0 4px 20px rgba(0,0,0,0.1);font-size:9px} #expPanel h3{font-size:11px;color:#1565C0;margin-bottom:10px; border-bottom:1px solid #EEE;padding-bottom:6px} #expPanel .ep-row{display:flex;align-items:center;justify-content:space-between; padding:4px 0;border-bottom:1px solid #F5F5F5} #expPanel .ep-row:last-child{border-bottom:none} #expPanel .ep-label{color:#555;font-size:9px;min-width:80px} #expPanel .ep-btns{display:flex;gap:4px;flex-wrap:wrap} #expPanel .ep-stat{display:flex;gap:8px;margin-bottom:8px; padding:6px;background:#F8F9FA;border-radius:6px} #expPanel .ep-stat-item{text-align:center;flex:1} #expPanel .ep-stat-n{font-size:14px;font-weight:bold} #expPanel .ep-stat-l{font-size:7px;color:#999} #expPanel .x{position:absolute;top:8px;right:10px;cursor:pointer;color:#CCC;font-size:14px} #expPanel .x:hover{color:#C62828} /* TXT Modal */ #txtModal{position:fixed;top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,0.4);z-index:200;display:none; justify-content:center;align-items:center} #txtBox{background:#fff;border-radius:10px;width:84vw;height:84vh; display:flex;flex-direction:column; box-shadow:0 12px 40px rgba(0,0,0,0.15);overflow:hidden} #txtHead{display:flex;align-items:center;justify-content:space-between; padding:8px 14px;border-bottom:1px solid #EEE;background:#FAFAFA} #txtHead span{font-size:12px;font-weight:bold} #txtPre{flex:1;overflow:auto;padding:14px;margin:0; font-family:'Consolas','Courier New',monospace;font-size:10px; line-height:1.55;color:#333;background:#fff;white-space:pre;tab-size:4} /* Chain Panel */ #chainPanel{position:fixed;top:52px;left:12px;background:rgba(255,255,255,0.98); border:1px solid #E0E0E0;border-radius:8px;padding:10px 14px; max-width:360px;max-height:60vh;overflow-y:auto;font-size:9px; z-index:99;display:none;line-height:1.8;box-shadow:0 3px 14px rgba(0,0,0,0.08)} #chainPanel .x{position:absolute;top:5px;right:8px;cursor:pointer;color:#ccc;font-size:14px} #chainPanel .x:hover{color:#C62828} .chain-step{padding:2px 4px;margin:1px 0;border-radius:3px;cursor:pointer;transition:background .15s} .chain-step:hover{background:#F3E5F5} .chain-step.active{background:#E1BEE7;font-weight:bold} </style> </head> <body> <div id="bar"> <span class="t">CallGraph</span> <span class="tag tag-biz">B:__BIZ_N__</span> <span class="tag tag-nb">NB:__NONBIZ_N__</span> <span class="tag tag-unk">U:__UNK_N__</span> <span class="s">__TN__n __TE__e __TC__g</span> <input id="search" placeholder="Search..." oninput="doSearch(this.value)"> <div class="sep"></div> <button class="b" onclick="fitAll()">Fit</button> <button class="b" onclick="resetAll()">All</button> <div class="sep"></div> <button class="b" onclick="showBySize(1,1)">Iso</button> <button class="b" onclick="showBySize(2,5)">S</button> <button class="b" onclick="showBySize(6,30)">M</button> <button class="b" onclick="showBySize(31,999999)">L</button> <div class="sep"></div> <button class="b" onclick="showCat('business')" style="color:#1565C0;border-color:#1565C0">Biz</button> <button class="b" onclick="showCat('non-business')" style="color:#E91E63;border-color:#E91E63">NonBiz</button> <button class="b" onclick="showCat('unknown')" style="color:#9E9E9E;border-color:#9E9E9E">Unk</button> <div class="sep"></div> <button id="toggleNBBtn" class="b pink" onclick="toggleNonBiz()">Hide NonBiz</button> <div class="sep"></div> <button class="b" onclick="isolateComp()">Comp</button> <button class="b" onclick="isolateChain()">Chain</button> <button class="b" onclick="focusCenter()">Center</button> <div class="sep"></div> <button class="b purple" onclick="showLongestChain()">Longest</button> <div class="sep"></div> <button class="b grn" onclick="showTxt()">TXT</button> <button class="b grn" onclick="dlTxt()">Save</button> <button class="b grn" onclick="dlNodeTxt()">Node</button> <button class="b" onclick="expPNG()">PNG</button> <div class="sep"></div> <!-- ★ 新增:调用链CSV导出按钮 --> <button class="b teal" onclick="expCallChainCSV()" title="导出调用链CSV(节点/调用者/被调用者)">📊 ChainCSV</button> <button class="b teal" onclick="expEdgeCSV()" title="导出边关系CSV">🔗 EdgeCSV</button> <div class="sep"></div> <button class="b orange" onclick="toggleExpPanel()" title="导出函数分类列表">💾 Export</button> </div> <!-- Export Panel --> <div id="expPanel"> <span class="x" onclick="closeExpPanel()">x</span> <h3>💾 函数分类导出</h3> <div class="ep-stat"> <div class="ep-stat-item"> <div class="ep-stat-n" style="color:#1565C0" id="epBizN">-</div> <div class="ep-stat-l">业务函数</div> </div> <div class="ep-stat-item"> <div class="ep-stat-n" style="color:#E91E63" id="epNbN">-</div> <div class="ep-stat-l">非业务函数</div> </div> <div class="ep-stat-item"> <div class="ep-stat-n" style="color:#9E9E9E" id="epUnkN">-</div> <div class="ep-stat-l">未知函数</div> </div> <div class="ep-stat-item"> <div class="ep-stat-n" style="color:#333" id="epTotalN">-</div> <div class="ep-stat-l">合计</div> </div> </div> <div class="ep-row"> <span class="ep-label">📊 CSV(节点)</span> <div class="ep-btns"> <button class="b grn" onclick="expCSV('all')">全部</button> <button class="b" style="color:#1565C0;border-color:#1565C0" onclick="expCSV('business')">仅业务</button> <button class="b" style="color:#E91E63;border-color:#E91E63" onclick="expCSV('non-business')">仅非业务</button> </div> </div> <div class="ep-row"> <span class="ep-label">🔗 CSV(调用链)</span> <div class="ep-btns"> <button class="b teal" onclick="expCallChainCSV()">全部调用链</button> <button class="b teal" onclick="expEdgeCSV()">边关系</button> </div> </div> <div class="ep-row"> <span class="ep-label">📄 JSON</span> <div class="ep-btns"> <button class="b grn" onclick="expJSON('all')">全部</button> <button class="b" style="color:#1565C0;border-color:#1565C0" onclick="expJSON('business')">仅业务</button> </div> </div> <div class="ep-row"> <span class="ep-label">📝 TXT</span> <div class="ep-btns"> <button class="b grn" onclick="expClassTxt('all')">全部</button> <button class="b" style="color:#1565C0;border-color:#1565C0" onclick="expClassTxt('business')">仅业务</button> </div> </div> <div class="ep-row"> <span class="ep-label">🔍 预览</span> <div class="ep-btns"> <button class="b" onclick="previewClass('all')">全部</button> <button class="b" style="color:#1565C0;border-color:#1565C0" onclick="previewClass('business')">业务</button> <button class="b" style="color:#E91E63;border-color:#E91E63" onclick="previewClass('non-business')">非业务</button> <button class="b" style="color:#9E9E9E;border-color:#9E9E9E" onclick="previewClass('unknown')">未知</button> </div> </div> <div style="margin-top:8px;font-size:7px;color:#BBB">按地址升序排列</div> </div> <div id="sr"></div> <div id="graph"></div> <div id="info"> <span class="x" onclick="this.parentElement.style.display='none'">x</span> <div id="ic"></div> </div> <div id="chainPanel"> <span class="x" onclick="closeLongestChain()">x</span> <div id="chainContent"></div> </div> <div id="legend"> <div class="lr"><span class="ld" style="background:#1565C0"></span>业务函数 Business</div> <div class="lr"><span class="ld" style="background:#E91E63"></span>非业务函数 Non-Business</div> <div class="lr"><span class="ld" style="background:#9E9E9E"></span>未知函数 Unknown</div> <div class="lr"><span class="ld" style="background:#7B1FA2"></span>最长链 Longest Chain</div> <div style="margin-top:4px;padding-top:4px;border-top:1px solid #eee;font-size:7px;color:#999"> 圆大小 = log(代码量) × 出度因子 </div> </div> <div id="mode"></div> <div id="back"> <button class="b red" style="padding:6px 16px;font-size:10px;border-radius:7px" onclick="resetAll()">Back</button> </div> <div id="stats"></div> <!-- TXT Modal --> <div id="txtModal" onclick="if(event.target===this)this.style.display='none'"> <div id="txtBox"> <div id="txtHead"> <span id="txtTitle">Report</span> <div style="display:flex;gap:5px"> <button class="b grn" id="txtDlBtn" onclick="dlCurrentTxt()">Save</button> <button class="b" onclick="document.getElementById('txtModal').style.display='none'">X</button> </div> </div> <pre id="txtPre"></pre> </div> </div> <script> var RN=__NODES__,RE=__EDGES__,AD=__ADJ_DOWN__,AU=__ADJ_UP__, COMPS=__COMPS__,TXT=__TXT__, LC=__LONGEST_CHAIN__,LCE=__LONGEST_CHAIN_EDGES__; var nodes=new vis.DataSet(RN),edges=new vis.DataSet(RE); var ctr=document.getElementById('graph'); var iso=false,chainActive=false,nbHidden=false; var cn={}; RN.forEach(function(n){var c=n.compId;if(!cn[c])cn[c]=[];cn[c].push(n.id);}); var centerNode=RN.reduce(function(a,b){return(a.totalDeg||0)>=(b.totalDeg||0)?a:b;},RN[0]||{id:0}); var origN={},origE={}; RN.forEach(function(n){ origN[n.id]={bg:n.color.background,border:n.color.border, hlBg:n.color.highlight.background,hlBorder:n.color.highlight.border, size:n.size,bw:n.borderWidth||0,cat:n.category}; }); RE.forEach(function(e){origE[e.id]={color:e.color.color,width:e.width,invNB:e.involvesNonBiz};}); var _currentTxtContent='',_currentTxtFilename='export.txt'; var net=new vis.Network(ctr,{nodes:nodes,edges:edges},{ layout:{randomSeed:42,improvedLayout:false}, physics:{enabled:false}, edges:{smooth:{type:'continuous',roundness:0.3},color:{inherit:false},selectionWidth:2}, nodes:{shadow:true,borderWidth:0,borderWidthSelected:0,chosen:false}, interaction:{hover:false,tooltipDelay:999999,dragNodes:true, dragView:false,zoomView:false,keyboard:false, multiselect:false,navigationButtons:false,hoverConnectedEdges:false} }); var vw={x:0,y:0,s:1},pn={a:false,sx:0,sy:0,vx:0,vy:0}; function sy(){var v=net.getViewPosition();vw.x=v.x;vw.y=v.y;vw.s=net.getScale();} ctr.addEventListener('mousedown',function(e){ if(e.button!==0||net.getNodeAt({x:e.offsetX,y:e.offsetY})!==undefined)return; sy();pn.a=true;pn.sx=e.clientX;pn.sy=e.clientY;pn.vx=vw.x;pn.vy=vw.y; ctr.style.cursor='grabbing';e.preventDefault();},true); window.addEventListener('mousemove',function(e){if(!pn.a)return; vw.x=pn.vx-(e.clientX-pn.sx)/vw.s;vw.y=pn.vy-(e.clientY-pn.sy)/vw.s; net.moveTo({position:{x:vw.x,y:vw.y},animation:false});},true); window.addEventListener('mouseup',function(e){if(!pn.a)return; vw.x=pn.vx-(e.clientX-pn.sx)/vw.s;vw.y=pn.vy-(e.clientY-pn.sy)/vw.s; net.moveTo({position:{x:vw.x,y:vw.y},animation:false}); pn.a=false;ctr.style.cursor='grab';},true); ctr.addEventListener('wheel',function(e){e.preventDefault();sy(); var f=e.deltaY<0?1.15:1/1.15,ns=Math.min(40,Math.max(0.002,vw.s*f)); var p=net.DOMtoCanvas({x:e.offsetX,y:e.offsetY}),r=vw.s/ns; vw.x=p.x-(p.x-vw.x)*r;vw.y=p.y-(p.y-vw.y)*r;vw.s=ns; net.moveTo({position:{x:vw.x,y:vw.y},scale:vw.s,animation:false}); },{passive:false}); setTimeout(function(){ net.fit({animation:{duration:500}}); var b=0,nb=0,u=0; RN.forEach(function(n){ if(n.category==='business')b++; else if(n.category==='non-business')nb++; else u++; }); us('Biz:'+b+' NonBiz:'+nb+' Unk:'+u+' | edges:'+RE.length); document.getElementById('epBizN').textContent=b; document.getElementById('epNbN').textContent=nb; document.getElementById('epUnkN').textContent=u; document.getElementById('epTotalN').textContent=RN.length; },200); function us(m){var e=document.getElementById('stats');e.textContent=m;e.style.display=m?'block':'none';} // ===================================================== // ★ 调用链CSV导出(核心新增函数) // ===================================================== var _CAT_MAP={'business':'业务函数','non-business':'非业务函数','unknown':'未知函数'}; var _CAT_COLOR={'business':'#1565C0','non-business':'#E91E63','unknown':'#9E9E9E'}; var _CAT_SHORT={'business':'B','non-business':'S','unknown':'?'}; /** * expCallChainCSV —— 导出调用链CSV * 列:node_address, node_name, node_category, node_category_cn, * node_size_b, node_in_degree, node_out_degree, * caller_address, caller_name, caller_category, * callee_address, callee_name, callee_category, * edge_direction */ function expCallChainCSV(){ var rows=[]; // BOM for Excel UTF-8 rows.push('\uFEFF'+'node_address,node_name,node_category,node_category_cn,' +'node_size_b,node_in_degree,node_out_degree,' +'caller_address,caller_name,caller_category,' +'callee_address,callee_name,callee_category,' +'edge_direction'); // 构建索引:callers_of[id]=[callerIds...], callees_of[id]=[calleeIds...] var callersOf={},calleesOf={}; RN.forEach(function(n){callersOf[n.id]=[];calleesOf[n.id]=[];}); RE.forEach(function(e){ if(calleesOf[e.from])calleesOf[e.from].push(e.to); if(callersOf[e.to])callersOf[e.to].push(e.from); }); // 节点按地址排序 var sorted=RN.slice().sort(function(a,b){ return parseInt(a.ea,16)-parseInt(b.ea,16); }); sorted.forEach(function(n){ var cList=callersOf[n.id]||[]; var eList=calleesOf[n.id]||[]; var nodeBase=[ n.ea, csvQ(n.fullName), n.category, _CAT_MAP[n.category]||n.category, n.funcSize, n.inDeg, n.outDeg ]; // 孤立节点 if(!cList.length&&!eList.length){ rows.push(nodeBase.concat(['','','','','','','isolated']).join(',')); return; } // caller→node 的边 cList.forEach(function(cid){ var cn2=nodes.get(cid)||{}; rows.push(nodeBase.concat([ cn2.ea||'', csvQ(cn2.fullName||''), cn2.category||'', '','','', csvQ((cn2.fullName||'?')+' --> '+n.fullName) ]).join(',')); }); // node→callee 的边 eList.forEach(function(tid){ var tn=nodes.get(tid)||{}; rows.push(nodeBase.concat([ '','','', tn.ea||'', csvQ(tn.fullName||''), tn.category||'', csvQ(n.fullName+' --> '+(tn.fullName||'?')) ]).join(',')); }); }); dl(rows.join('\r\n'),'callchain_node_caller_callee.csv'); closeExpPanel(); } /** * expEdgeCSV —— 以边为单位导出CSV * 每行 = 一条调用关系(caller→callee) */ function expEdgeCSV(){ var rows=[]; rows.push('\uFEFF'+'edge_id,caller_address,caller_name,caller_category,caller_cat_cn,caller_size_b,' +'callee_address,callee_name,callee_category,callee_cat_cn,callee_size_b,edge_type'); var sortedEdges=RE.slice().sort(function(a,b){return a.id-b.id;}); sortedEdges.forEach(function(e,i){ var fn=nodes.get(e.from)||{}; var tn=nodes.get(e.to)||{}; var fcat=fn.category||'unknown'; var tcat=tn.category||'unknown'; var etype=(_CAT_SHORT[fcat]||'?')+'->'+(_CAT_SHORT[tcat]||'?'); rows.push([ i+1, fn.ea||'', csvQ(fn.fullName||''), fcat, _CAT_MAP[fcat]||fcat, fn.funcSize||0, tn.ea||'', csvQ(tn.fullName||''), tcat, _CAT_MAP[tcat]||tcat, tn.funcSize||0, etype ].join(',')); }); dl(rows.join('\r\n'),'callchain_edges.csv'); closeExpPanel(); } /** CSV字段转义:含逗号/引号/换行时加双引号 */ function csvQ(s){ s=String(s||''); if(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0){ return '"'+s.replace(/"/g,'""')+'"'; } return s; } // ===================================================== // Export Panel // ===================================================== function toggleExpPanel(){ var p=document.getElementById('expPanel'); p.style.display=p.style.display==='none'||!p.style.display?'block':'none'; } function closeExpPanel(){document.getElementById('expPanel').style.display='none';} document.addEventListener('click',function(e){ var panel=document.getElementById('expPanel'); var btn=document.querySelector('[onclick="toggleExpPanel()"]'); if(panel.style.display==='block'&&!panel.contains(e.target)&&e.target!==btn) panel.style.display='none'; }); function _filterNodes(cat){ var sorted=RN.slice().sort(function(a,b){return parseInt(a.ea,16)-parseInt(b.ea,16);}); if(cat==='all')return sorted; return sorted.filter(function(n){return n.category===cat;}); } function expCSV(cat){ var list=_filterNodes(cat); var rows=['\uFEFF'+'Address,Name,Category_EN,Category_CN,Code_Size_B,In_Degree,Out_Degree,Is_Entry,Component_ID,Component_Size']; list.forEach(function(n){ rows.push([n.ea,csvQ(n.fullName),n.category,_CAT_MAP[n.category]||n.category, n.funcSize,n.inDeg,n.outDeg,n.isEntry?'true':'false',n.compId,n.compSize].join(',')); }); dl(rows.join('\r\n'),'func_classification'+(cat==='all'?'':'_'+cat)+'.csv'); closeExpPanel(); } function expJSON(cat){ var list=_filterNodes(cat); var bizN=RN.filter(function(n){return n.category==='business';}).length; var nbN=RN.filter(function(n){return n.category==='non-business';}).length; var unkN=RN.filter(function(n){return n.category==='unknown';}).length; var result={meta:{filter:cat,total:RN.length,exported:list.length, business:bizN,nonBusiness:nbN,unknown:unkN,edges:RE.length}, functions:list.map(function(n){return{address:n.ea,name:n.fullName, category:n.category,category_cn:_CAT_MAP[n.category]||n.category, code_size_b:n.funcSize,in_degree:n.inDeg,out_degree:n.outDeg, is_entry:n.isEntry,component_id:n.compId,component_size:n.compSize};})}; dl(JSON.stringify(result,null,2),'func_classification'+(cat==='all'?'':'_'+cat)+'.json'); closeExpPanel(); } function expClassTxt(cat){ var list=_filterNodes(cat);var W=80;var L=[]; var bizN=RN.filter(function(n){return n.category==='business';}).length; var nbN=RN.filter(function(n){return n.category==='non-business';}).length; var unkN=RN.filter(function(n){return n.category==='unknown';}).length; L.push('='.repeat(W));L.push(' 函数分类报告'); L.push(' 过滤:'+(cat==='all'?'全部':_CAT_MAP[cat]||cat)+' 导出:'+list.length+'/'+RN.length); L.push(' 业务:'+bizN+' 非业务:'+nbN+' 未知:'+unkN); L.push('='.repeat(W));L.push(''); var groups={'business':{label:'■ 业务函数',items:[]},'non-business':{label:'■ 非业务函数',items:[]},'unknown':{label:'■ 未知函数',items:[]}}; list.forEach(function(n){groups[n.category].items.push(n);}); var order=cat==='all'?['business','non-business','unknown']:[cat]; order.forEach(function(g){ var grp=groups[g];if(!grp.items.length)return; L.push(grp.label+' ('+grp.items.length+')');L.push('-'.repeat(W)); L.push(' '+['Address'.padEnd(12),'CodeSize'.padEnd(10),'In'.padEnd(5),'Out'.padEnd(5),'Name'].join(' ')); grp.items.forEach(function(n){ L.push(' '+[n.ea.padEnd(12),(n.funcSize+'B').padEnd(10), String(n.inDeg).padEnd(5),String(n.outDeg).padEnd(5),n.fullName].join(' ')); });L.push(''); }); L.push('='.repeat(W)); dl(L.join('\n'),'func_classification'+(cat==='all'?'':'_'+cat)+'.txt'); closeExpPanel(); } function previewClass(cat){ var list=_filterNodes(cat);var W=80;var L=[]; L.push('='.repeat(W)); L.push(' 函数分类预览 — '+(cat==='all'?'全部 ('+RN.length+')':(_CAT_MAP[cat]||cat)+' ('+list.length+')')); L.push('='.repeat(W));L.push(''); var TAG={'business':'[B]','non-business':'[S]','unknown':'[?]'}; list.forEach(function(n,i){ L.push((i+1+'').padStart(5)+'. '+TAG[n.category]+' ' +n.ea.padEnd(12)+(n.funcSize+'B').padEnd(10) +'in:'+String(n.inDeg).padEnd(4)+'out:'+String(n.outDeg).padEnd(4)+n.fullName); }); L.push('');L.push('='.repeat(W)); var content=L.join('\n'); _currentTxtContent=content; _currentTxtFilename='func_classification'+(cat==='all'?'':'_'+cat)+'.txt'; document.getElementById('txtTitle').textContent='预览: '+(cat==='all'?'全部':_CAT_MAP[cat]||cat)+' ('+list.length+'个)'; document.getElementById('txtPre').textContent=content; document.getElementById('txtModal').style.display='flex'; closeExpPanel(); } // ===================================================== // Longest Chain // ===================================================== function showLongestChain(){ if(!LC||!LC.length){alert('No chain');return;} chainActive=true; var cs=new Set(LC),ces=new Set(LCE); nodes.update(RN.map(function(n){ if(cs.has(n.id))return{id:n.id,hidden:false, color:{background:'#7B1FA2',border:'#4A148C', highlight:{background:'#9C27B0',border:'#4A148C'}}, borderWidth:3,size:Math.max((origN[n.id]||{}).size||12,20), shadow:{enabled:true,color:'rgba(123,31,162,0.3)',size:15,x:0,y:0}}; return{id:n.id,hidden:false, color:{background:'rgba(200,200,200,0.3)',border:'rgba(200,200,200,0.3)', highlight:{background:'rgba(200,200,200,0.5)',border:'rgba(200,200,200,0.5)'}}, borderWidth:0,size:Math.max(((origN[n.id]||{}).size||12)*0.6,6), shadow:{enabled:false}};})); edges.update(RE.map(function(e){ if(ces.has(e.id))return{id:e.id,hidden:false, color:{color:'#7B1FA2',highlight:'#4A148C'},width:3.5, arrows:{to:{enabled:true,scaleFactor:0.7,type:'arrow'}}}; return{id:e.id,hidden:false,color:{color:'rgba(200,200,200,0.08)'},width:0.5};})); buildChainPanel(); if(LC.length)net.fit({nodes:LC,animation:{duration:600}}); iso=true;sb();sm('Chain ('+LC.length+' nodes)'); } function buildChainPanel(){ var html='<div style="font-weight:bold;color:#7B1FA2;margin-bottom:6px;font-size:11px">Longest Chain ('+LC.length+')</div>'; html+='<div style="color:#999;font-size:8px;margin-bottom:8px">Click to focus</div>'; LC.forEach(function(nid,idx){ var nd=nodes.get(nid);if(!nd)return; var cc=_CAT_COLOR[nd.category]||'#999'; var sl=idx===0?'START':idx===LC.length-1?'END':'#'+idx; var sc=idx===0?'#4CAF50':idx===LC.length-1?'#F44336':'#7B1FA2'; html+='<div class="chain-step" id="cs_'+idx+'" onclick="fcn('+nid+','+idx+')">'; html+='<span style="color:'+sc+';font-weight:bold;font-size:8px">['+sl+']</span> '; html+='<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:'+cc+';margin-right:3px"></span>'; html+=esc(nd.fullName)+' <span style="color:#BBB;font-size:7px">'+nd.ea+'</span>'; html+='</div>'; if(idx<LC.length-1)html+='<div style="text-align:center;color:#7B1FA2">↓</div>'; }); html+='<div style="margin-top:8px;padding-top:6px;border-top:1px solid #EEE"><button class="b" onclick="closeLongestChain()">Close</button></div>'; document.getElementById('chainContent').innerHTML=html; document.getElementById('chainPanel').style.display='block'; } function fcn(nid,idx){ net.focus(nid,{scale:2.0,animation:{duration:400}}); net.selectNodes([nid]);showInfo(nid); document.querySelectorAll('.chain-step').forEach(function(el){el.classList.remove('active');}); var el=document.getElementById('cs_'+idx);if(el)el.classList.add('active'); } function closeLongestChain(){ chainActive=false; document.getElementById('chainPanel').style.display='none'; nodes.update(RN.map(function(n){ var oc=origN[n.id]||{}; return{id:n.id,hidden:false, color:{background:oc.bg,border:oc.border, highlight:{background:oc.hlBg||oc.bg,border:oc.hlBorder||oc.border}}, borderWidth:oc.bw||0,size:oc.size||12, shadow:{enabled:true,color:'rgba(0,0,0,0.1)',size:8,x:0,y:3}};})); edges.update(RE.map(function(e){ var oe=origE[e.id]||{}; return{id:e.id,hidden:false,color:{color:oe.color,highlight:'#C62828'},width:oe.width||1};})); iso=false;nbHidden=false; var btn=document.getElementById('toggleNBBtn'); btn.textContent='Hide NonBiz';btn.classList.remove('active'); ['back','mode'].forEach(function(x){document.getElementById(x).style.display='none';}); net.fit({animation:{duration:400}}); } // ===================================================== // 视图控制 // ===================================================== function showCat(cat){ if(chainActive)closeLongestChain(); var k=new Set();RN.forEach(function(n){if(n.category===cat)k.add(n.id);}); af(k);iso=true;sb();sm(cat+' ('+k.size+'n)'); setTimeout(function(){net.fit({animation:{duration:300}});},100); } function showBySize(lo,hi){ if(chainActive)closeLongestChain(); var k=new Set(); COMPS.forEach(function(c){if(c.size>=lo&&c.size<=hi)(cn[c.id]||[]).forEach(function(n){k.add(n);});}); af(k);iso=true;sb();sm('size '+lo+'-'+hi+' ('+k.size+'n)'); setTimeout(function(){net.fit({animation:{duration:300}});},100); } function isolateComp(){ var s=net.getSelectedNodes();if(!s.length){alert('Select node');return;} if(chainActive)closeLongestChain(); var n=nodes.get(s[0]);if(!n)return; var k=new Set(cn[n.compId]||[]); af(k);iso=true;sb();sm('comp#'+n.compId+' ('+k.size+'n)'); setTimeout(function(){net.fit({animation:{duration:300}});},100); } function isolateChain(){ var s=net.getSelectedNodes();if(!s.length){alert('Select node');return;} if(chainActive)closeLongestChain(); var k=ta(s[0],500);af(k);iso=true;sb(); var n=nodes.get(s[0]);sm('chain:'+(n?n.fullName:'')+' ('+k.size+'n)'); setTimeout(function(){net.fit({animation:{duration:300}});},100); } function focusCenter(){ if(chainActive)closeLongestChain(); if(centerNode&¢erNode.id!==undefined){ net.focus(centerNode.id,{scale:1.5,animation:{duration:600}}); net.selectNodes([centerNode.id]);showInfo(centerNode.id); } } function af(k){ nodes.update(RN.map(function(n){return{id:n.id,hidden:!k.has(n.id)};})); edges.update(RE.map(function(e){return{id:e.id,hidden:!(k.has(e.from)&&k.has(e.to))};})); } function sb(){document.getElementById('back').style.display='block';} function sm(t){var m=document.getElementById('mode');m.textContent=t;m.style.display='block';} function resetAll(){ if(chainActive){closeLongestChain();return;} nbHidden=false; var btn=document.getElementById('toggleNBBtn'); btn.textContent='Hide NonBiz';btn.classList.remove('active'); nodes.update(RN.map(function(n){ var oc=origN[n.id]||{}; return{id:n.id,hidden:false, color:{background:oc.bg,border:oc.border, highlight:{background:oc.hlBg||oc.bg,border:oc.hlBorder||oc.border}}, borderWidth:oc.bw||0,size:oc.size||12, shadow:{enabled:true,color:'rgba(0,0,0,0.1)',size:8,x:0,y:3}};})); edges.update(RE.map(function(e){ var oe=origE[e.id]||{}; return{id:e.id,hidden:false,color:{color:oe.color,highlight:'#C62828'},width:oe.width||1};})); net.unselectAll();iso=false; ['back','info','mode','chainPanel'].forEach(function(x){document.getElementById(x).style.display='none';}); net.fit({animation:{duration:400}}); } function fitAll(){net.fit({animation:{duration:400}});} function toggleNonBiz(){ var btn=document.getElementById('toggleNBBtn'); if(chainActive)closeLongestChain(); nbHidden=!nbHidden; if(nbHidden){ btn.textContent='Show NonBiz';btn.classList.add('active'); nodes.update(RN.map(function(n){return{id:n.id,hidden:n.category==='non-business'};})); edges.update(RE.map(function(e){return{id:e.id,hidden:e.involvesNonBiz};})); iso=true;sb();sm('Non-business hidden'); }else{btn.textContent='Hide NonBiz';btn.classList.remove('active');resetAll();return;} setTimeout(function(){net.fit({animation:{duration:300}});},100); } // ===================================================== // 图遍历辅助 // ===================================================== function td(id,l){var s=new Set([id]),q=[id]; while(q.length){var c=q.shift();(AD[c]||[]).forEach(function(n){if(!s.has(n)){s.add(n);if(s.size<l)q.push(n);}});}return s;} function tu(id,l){var s=new Set([id]),q=[id]; while(q.length){var c=q.shift();(AU[c]||[]).forEach(function(n){if(!s.has(n)){s.add(n);if(s.size<l)q.push(n);}});}return s;} function ta(id,l){var s=tu(id,l);td(id,l).forEach(function(v){s.add(v);});return s;} // ===================================================== // 节点信息 // ===================================================== function showInfo(id){ var n=nodes.get(id);if(!n)return; var cc=_CAT_COLOR[n.category]||'#999'; var isC=(centerNode&&n.id===centerNode.id)?' <b style="color:#C62828">[CENTER]</b>':''; var ci=LC.indexOf(n.id); var isL=ci>=0?' <b style="color:#7B1FA2">[chain#'+ci+']</b>':''; var badge='<span style="background:'+cc+';color:#fff;padding:1px 5px;border-radius:3px;font-size:8px">'+n.catLabel+'</span>'; document.getElementById('ic').innerHTML= "<div style='font-family:monospace;line-height:1.8'>" +"<b style='color:"+cc+"'>"+esc(n.fullName)+"</b> "+badge+isC+isL+"<br>" +"<span style='color:#999'>addr</span> "+n.ea+" <span style='color:#999'>size</span> "+n.funcSize+"B<br>" +"<span style='color:#999'>comp#</span>"+n.compId+" ("+n.compSize+"n)<br>" +"<span style='color:#999'>in</span><b style='color:"+cc+"'>"+n.inDeg+"</b>" +" <span style='color:#999'>out</span><b style='color:"+cc+"'>"+n.outDeg+"</b>" +" <span style='color:#999'>deg</span><b style='color:"+cc+"'>"+(n.totalDeg||0)+"</b>" +"<br><span style='color:#999'>circle</span> "+Math.round(n.size)+"px" +"</div>"; document.getElementById('info').style.display='block'; } net.on('click',function(p){if(p.nodes.length)showInfo(p.nodes[0]);}); net.on('doubleClick',function(p){ if(!p.nodes.length)return; var n=nodes.get(p.nodes[0]);if(!n)return; if(chainActive)closeLongestChain(); var k=new Set(cn[n.compId]||[]); af(k);iso=true;sb();sm('comp#'+n.compId+' ('+k.size+'n)'); net.selectNodes([p.nodes[0]]); setTimeout(function(){net.fit({animation:{duration:300}});},100); }); // ===================================================== // 搜索 // ===================================================== function doSearch(q){ var box=document.getElementById('sr'); if(!q||q.length<2){box.style.display='none';return;} q=q.toLowerCase(); var m=RN.filter(function(n){ return n.fullName.toLowerCase().indexOf(q)>=0||n.ea.toLowerCase().indexOf(q)>=0; }).slice(0,25); if(!m.length){box.style.display='none';return;} box.innerHTML=''; m.forEach(function(n){ var d=document.createElement('div');d.className='si'; var cc=_CAT_COLOR[n.category]||'#999'; d.innerHTML='<span style="color:'+cc+'">●</span> '+esc(n.fullName) +' <span class="sa">'+n.ea+'</span>' +' <span style="color:'+cc+';font-size:7px">'+n.catLabel+'</span>'; d.onclick=function(){ box.style.display='none';document.getElementById('search').value=''; if(chainActive)closeLongestChain(); if(iso)resetAll(); setTimeout(function(){net.focus(n.id,{scale:1.8,animation:true}); net.selectNodes([n.id]);showInfo(n.id);},iso?400:50); }; box.appendChild(d); }); box.style.display='block'; } document.addEventListener('click',function(e){ if(e.target.id!=='search')document.getElementById('sr').style.display='none'; }); // ===================================================== // TXT 导出 // ===================================================== function buildNodeTxt(){ var sel=net.getSelectedNodes();if(!sel.length)return null; var nid=sel[0],nd=nodes.get(nid);if(!nd)return null; var W=80,L=[]; L.push('='.repeat(W));L.push(' '+nd.fullName+' ['+nd.catLabel+']'); L.push(' '+nd.ea+' '+nd.catLabel+' '+nd.funcSize+'B comp#'+nd.compId); L.push('='.repeat(W));L.push(''); var up=AU[nid]||[],dn=AD[nid]||[]; L.push('CALLERS ('+up.length+')');L.push('-'.repeat(W)); if(!up.length)L.push(' (none)'); up.forEach(function(c){var n2=nodes.get(c);if(n2)L.push(' ['+n2.catLabel+'] '+n2.fullName+' --> '+nd.fullName);}); L.push('');L.push('CALLEES ('+dn.length+')');L.push('-'.repeat(W)); if(!dn.length)L.push(' (none)'); dn.forEach(function(c){var n2=nodes.get(c);if(n2)L.push(' '+nd.fullName+' --> ['+n2.catLabel+'] '+n2.fullName);}); L.push('');L.push('='.repeat(W)); return L.join('\n'); } function showTxt(){ _currentTxtContent=TXT;_currentTxtFilename='full_callgraph.txt'; document.getElementById('txtTitle').textContent='Full Call Graph Report'; document.getElementById('txtPre').textContent=TXT; document.getElementById('txtModal').style.display='flex'; } function dlTxt(){dl(TXT,'full_callgraph.txt');} function dlNodeTxt(){ var t=buildNodeTxt();if(!t){alert('Select node');return;} var nd=nodes.get(net.getSelectedNodes()[0]); _currentTxtContent=t;_currentTxtFilename=(nd?nd.fullName:'node')+'.txt'; document.getElementById('txtTitle').textContent=nd?nd.fullName:'Node Report'; document.getElementById('txtPre').textContent=t; document.getElementById('txtModal').style.display='flex'; } function dlCurrentTxt(){ if(_currentTxtContent)dl(_currentTxtContent,_currentTxtFilename); else dl(TXT,'full_callgraph.txt'); } // ===================================================== // 通用工具 // ===================================================== function dl(t,n){ var b=new Blob([t],{type:'text/plain;charset=utf-8'}),a=document.createElement('a'); a.download=n;a.href=URL.createObjectURL(b);a.click();URL.revokeObjectURL(a.href); } function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML;} function expPNG(){ var c=ctr.getElementsByTagName('canvas')[0];if(!c)return; var a=document.createElement('a');a.download='callgraph.png'; a.href=c.toDataURL('image/png');a.click(); } </script> </body> </html>''' page = page.replace('__NODES__', json.dumps(vn, ensure_ascii=False)) page = page.replace('__EDGES__', json.dumps(ve, ensure_ascii=False)) page = page.replace('__ADJ_DOWN__', json.dumps(adj_dd, ensure_ascii=False)) page = page.replace('__ADJ_UP__', json.dumps(adj_ud, ensure_ascii=False)) page = page.replace('__COMPS__', json.dumps(comp_info, ensure_ascii=False)) page = page.replace('__TXT__', txt_esc) page = page.replace('__LONGEST_CHAIN__', json.dumps(lc_ids, ensure_ascii=False)) page = page.replace('__LONGEST_CHAIN_EDGES__', json.dumps(lc_edge_ids, ensure_ascii=False)) page = page.replace('__TN__', str(len(vn))) page = page.replace('__TE__', str(len(ve))) page = page.replace('__TC__', str(len(components))) page = page.replace('__BIZ_N__', str(biz_n)) page = page.replace('__NONBIZ_N__',str(nb_n)) page = page.replace('__UNK_N__', str(unk_n)) with open(output_path, 'w', encoding='utf-8') as f: f.write(page) print("[+] HTML: {}".format(output_path)) # ===================================================== # DOT / ASCII 导出 # ===================================================== def generate_dot(nodes, edges): tc = {"business": ("#1565C0","#FFFFFF"), "non-business": ("#E91E63","#FFFFFF"), "unknown": ("#9E9E9E","#333333")} L = ['digraph FullCallGraph {', ' rankdir=LR;', ' node [shape=box,style="rounded,filled",fontsize=10];', ''] for ea, info in sorted(nodes.items(), key=lambda x: x[1]["name"]): sn = info["name"].replace('"', '\\"') fill, fc = tc.get(info.get("category","unknown"), tc["unknown"]) L.append(' "{}{:X}" [label="{}",fillcolor="{}",fontcolor="{}"];'.format( sn, ea, sn, fill, fc)) L.append('') for e in edges: s = nodes[e["from"]]; d = nodes[e["to"]] si = '{}{:X}'.format(s["name"].replace('"','\\"'), e["from"]) di = '{}{:X}'.format(d["name"].replace('"','\\"'), e["to"]) L.append(' "{}" -> "{}";'.format(si, di)) L.append('}') return '\n'.join(L) def generate_fallback_ascii(nodes, edges): call_map = defaultdict(list) called_by = defaultdict(list) for e in edges: call_map[e["from"]].append(e["to"]) called_by[e["to"]].append(e["from"]) W = 90 L = ["+{}+".format("="*(W-2)), "|{:^{w}}|".format("FULL FUNCTION CALL GRAPH", w=W-2), "|{:^{w}}|".format("{} nodes, {} edges".format(len(nodes),len(edges)), w=W-2), "+{}+".format("="*(W-2)), ""] roots = sorted( [ea for ea in nodes if not called_by.get(ea)], key=lambda x: (-nodes[x].get("out",0), nodes[x]["name"])) if not roots: roots = sorted(nodes.keys(), key=lambda x: (-_degree(x,nodes), nodes[x]["name"]))[:3] visited = set(); layers = []; current = list(roots) while current: layer = [ea for ea in current if ea not in visited] for ea in layer: visited.add(ea) if not layer: break layers.append(layer) nxt = [] for ea in layer: for t in call_map.get(ea,[]): if t not in visited: nxt.append(t) current = nxt remaining = [ea for ea in nodes if ea not in visited] if remaining: layers.append(remaining) tags = {"business":"[B]","non-business":"[S]","unknown":"[?]"} for li, layer in enumerate(layers): lbl = "ROOTS" if li==0 else "Layer {}".format(li) L.extend(["", " +-- {} --+".format(lbl)]) for ea in sorted(layer, key=lambda x: nodes[x]["name"]): info = nodes[ea] callees = [nodes[t]["name"] for t in call_map.get(ea,[]) if t in nodes] L.append(" | {} {} in:{} out:{} 0x{:X}".format( tags.get(info.get("category","unknown"),"[?]"), info["name"][:40], info["in"], info["out"], ea)) for ce in callees[:5]: L.append(" | --> {}".format(ce[:35])) if len(callees)>5: L.append(" | ... (+{} more)".format(len(callees)-5)) L.extend(["", "+{}+".format("="*(W-2))]) return "\n".join(L) def export_ascii_via_graph_easy(nodes, edges, dot_path, ascii_path): dot_content = generate_dot(nodes, edges) with open(dot_path, 'w', encoding='utf-8') as f: f.write(dot_content) print("[+] DOT: {}".format(dot_path)) ascii_text = None try: r = subprocess.run(['graph-easy','--from=dot','--as=ascii'], input=dot_content, capture_output=True, text=True, timeout=120) if r.returncode==0 and r.stdout.strip(): ascii_text = r.stdout print("[+] graph-easy OK") else: print("[!] graph-easy error:", r.stderr[:200] if r.stderr else "") except FileNotFoundError: print("[!] graph-easy not installed") except subprocess.TimeoutExpired: print("[!] graph-easy timeout") except Exception as ex: print("[!] graph-easy:", ex) if ascii_text is None: print("[*] Fallback ASCII renderer...") ascii_text = generate_fallback_ascii(nodes, edges) with open(ascii_path, 'w', encoding='utf-8') as f: f.write(ascii_text) print("[+] ASCII: {}".format(ascii_path)) return ascii_text # ===================================================== # 主流程 # ===================================================== print("") print("=" * 60) print(" IDA Call Graph — Business / Non-Business / Unknown") print(" + Lib-Callee Propagation + CallChain CSV Export") print("=" * 60) print("") _func_categories, _entry_points = scan_and_classify_all() if not _func_categories: print("[!] No functions found") else: _nodes, _edges = build_full_call_graph(_func_categories, _entry_points) if not _nodes: print("[!] No nodes") else: _inp = ida_nalt.get_input_file_path() or "" _dir = os.path.dirname(_inp) if _inp else os.path.expanduser("~") _bin = ida_nalt.get_root_filename() or "binary" _base = os.path.splitext(_bin)[0] W = 88 print("\n" + "="*W) print(" FULL GRAPH — {} nodes, {} edges".format(len(_nodes), len(_edges))) print("="*W) _cc = defaultdict(int) for info in _nodes.values(): _cc[info["category"]] += 1 print(" Business : {}".format(_cc.get("business",0))) print(" Non-business: {}".format(_cc.get("non-business",0))) print(" Unknown : {}".format(_cc.get("unknown",0))) print("="*W) print("\n[*] Finding components...") _comps = find_components(_nodes, _edges) print("[*] {} components".format(len(_comps))) print("[*] Longest chain...") _lc = find_longest_chain(_nodes, _edges) # 文本报告 _txt_path = os.path.join(_dir, "{}_full_callgraph.txt".format(_base)) _txt = generate_text(_nodes, _edges, _comps, _lc, _txt_path) # HTML 可视化 _html_path = os.path.join(_dir, "{}_full_callgraph.html".format(_base)) generate_html(_nodes, _edges, _comps, _txt, _lc, _html_path) # DOT / ASCII _dot_path = os.path.join(_dir, "{}_full_callgraph.dot".format(_base)) _ascii_path = os.path.join(_dir, "{}_full_callgraph_ascii.txt".format(_base)) export_ascii_via_graph_easy(_nodes, _edges, _dot_path, _ascii_path) # ★ 新增:调用链CSV导出(三种格式) # 1. 节点为主视角(节点函数 + 调用者 + 被调用者) _cc_csv_path = os.path.join(_dir, "{}_callchain_node_caller_callee.csv".format(_base)) generate_callchain_csv(_nodes, _edges, _cc_csv_path) # 2. 边为主视角(每条调用关系一行) _edge_csv_path = os.path.join(_dir, "{}_callchain_edges.csv".format(_base)) generate_edge_csv(_nodes, _edges, _edge_csv_path) # 3. 链路径序列(枚举所有路径,每步一行) _seq_csv_path = os.path.join(_dir, "{}_callchain_sequence.csv".format(_base)) generate_chain_sequence_csv(_nodes, _edges, _seq_csv_path, max_chains=10000) print("\n" + "="*60) print(" OUTPUT FILES") print("="*60) print(" TXT : {}".format(_txt_path)) print(" HTML : {}".format(_html_path)) print(" DOT : {}".format(_dot_path)) print(" ASCII : {}".format(_ascii_path)) print(" ★ CSV(节点) : {}".format(_cc_csv_path)) print(" ★ CSV(边) : {}".format(_edge_csv_path)) print(" ★ CSV(链序) : {}".format(_seq_csv_path)) print("="*60)
只导出指定函数的
# -*- coding: utf-8 -*- """ IDAPython 完整调用链分析工具(双向:调用者 + 被调用者) 功能:输入函数名称,递归展开调用链,打印并导出 支持:Thunk函数透明化、递归检测、多种导出格式、GUI输入、双向分析 """ import idautils import idaapi import ida_name import ida_kernwin import idc import os import json from collections import deque # ============================================================ # 方向常量 # ============================================================ DIR_CALLEES = 0 # 被调用者:这个函数调用了谁 DIR_CALLERS = 1 # 调用者:谁调用了这个函数 DIR_BOTH = 2 # 双向 DIR_LABELS = { DIR_CALLEES: "Callees (who does it call)", DIR_CALLERS: "Callers (who calls it)", DIR_BOTH: "Both directions", } # ============================================================ # 核心工具函数 # ============================================================ def get_func_name_clean(ea): """获取函数名称(兼容新旧版本IDA)""" name = idc.get_func_name(ea) if not name: try: name = ida_name.get_name(ea, ida_name.GN_VISIBLE) except TypeError: name = ida_name.get_name(ea) return name if name else "sub_%X" % ea def is_thunk(ea): """判断是否为thunk函数""" func = idaapi.get_func(ea) if not func: return False return bool(func.flags & idaapi.FUNC_THUNK) def resolve_thunk(ea, depth=0): """ 解析thunk函数,返回最终目标函数的(地址, 名称)。 支持多级thunk链,depth防止无限递归。 """ if depth > 10: return ea, get_func_name_clean(ea) func = idaapi.get_func(ea) if not func: return ea, get_func_name_clean(ea) if not (func.flags & idaapi.FUNC_THUNK): return ea, get_func_name_clean(ea) current = func.start_ea while current != idaapi.BADADDR and current < func.end_ea: mnem = idc.print_insn_mnem(current) if mnem and mnem.lower() in ('jmp', 'b', 'br', 'bx'): target = idc.get_operand_value(current, 0) if target and target != idaapi.BADADDR and target != ea: return resolve_thunk(target, depth + 1) current = idc.next_head(current, func.end_ea) for xref in idautils.XrefsFrom(func.start_ea, 0): if xref.type in (idaapi.fl_JN, idaapi.fl_JF, idaapi.fl_CF, idaapi.fl_CN): if xref.to != ea and xref.to != idaapi.BADADDR: return resolve_thunk(xref.to, depth + 1) return ea, get_func_name_clean(ea) def is_library_or_external(ea): """判断是否为外部/库函数""" func = idaapi.get_func(ea) if not func: return True seg = idaapi.getseg(ea) if seg: seg_name = idaapi.get_segm_name(seg) if seg_name and seg_name.lower() in ( '.idata', '.plt', 'extern', '.got', '.got.plt'): return True if seg.type == idaapi.SEG_XTRN: return True return False # ============================================================ # 被调用者分析引擎 (Callee) # ============================================================ def get_direct_callees(func_ea): """获取func_ea直接调用的所有函数地址集合""" callees = set() func = idaapi.get_func(func_ea) if not func: return callees current = func.start_ea while current != idaapi.BADADDR and current < func.end_ea: for xref in idautils.XrefsFrom(current, 0): if xref.type in (idaapi.fl_CF, idaapi.fl_CN): target_ea = xref.to if target_ea == idaapi.BADADDR: continue resolved_ea, _ = resolve_thunk(target_ea) callees.add(resolved_ea) current = idc.next_head(current, func.end_ea) return callees def build_callee_graph(func_ea, max_depth=0): """ BFS构建被调用者图。 返回: (graph, all_nodes, depth_map) graph: {caller_ea: set(callee_ea, ...)} 箭头含义: caller -> callee (A调用B) """ graph = {} all_nodes = set() depth_map = {} queue = deque() all_nodes.add(func_ea) queue.append((func_ea, 0)) depth_map[func_ea] = 0 visited_for_expansion = set() while queue: current_ea, depth = queue.popleft() if current_ea in visited_for_expansion: continue visited_for_expansion.add(current_ea) if max_depth > 0 and depth >= max_depth: continue if is_library_or_external(current_ea): continue callees = get_direct_callees(current_ea) if not callees: continue resolved_callees = set() for c_ea in callees: resolved_ea, _ = resolve_thunk(c_ea) resolved_callees.add(resolved_ea) all_nodes.add(resolved_ea) if resolved_ea not in depth_map: depth_map[resolved_ea] = depth + 1 if resolved_ea not in visited_for_expansion: queue.append((resolved_ea, depth + 1)) if resolved_callees: graph[current_ea] = resolved_callees return graph, all_nodes, depth_map # ============================================================ # 调用者分析引擎 (Caller) # ============================================================ def get_direct_callers(func_ea): """获取直接调用func_ea的所有函数地址集合""" callers = set() func = idaapi.get_func(func_ea) # 收集所有对该函数的代码交叉引用(被调用) target_addrs = set() target_addrs.add(func_ea) if func: target_addrs.add(func.start_ea) for target in target_addrs: for xref in idautils.XrefsTo(target, 0): if xref.type in (idaapi.fl_CF, idaapi.fl_CN): caller_func = idaapi.get_func(xref.frm) if caller_func: resolved_ea, _ = resolve_thunk(caller_func.start_ea) callers.add(resolved_ea) # 如果自身是thunk的目标,也检查thunk的调用者 for xref in idautils.XrefsTo(func_ea, 0): if xref.type in (idaapi.fl_JN, idaapi.fl_JF): thunk_func = idaapi.get_func(xref.frm) if thunk_func and is_thunk(thunk_func.start_ea): # 递归获取thunk的调用者 thunk_callers = get_direct_callers(thunk_func.start_ea) callers.update(thunk_callers) # 排除自身 callers.discard(func_ea) return callers def build_caller_graph(func_ea, max_depth=0): """ BFS构建调用者图。 返回: (graph, all_nodes, depth_map) graph: {callee_ea: set(caller_ea, ...)} 箭头含义: caller -> callee (即graph[被调用函数] = {调用它的函数们}) 注意:为了统一输出,graph存储方式为 {node: set(parents)} """ graph = {} all_nodes = set() depth_map = {} queue = deque() all_nodes.add(func_ea) queue.append((func_ea, 0)) depth_map[func_ea] = 0 visited_for_expansion = set() while queue: current_ea, depth = queue.popleft() if current_ea in visited_for_expansion: continue visited_for_expansion.add(current_ea) if max_depth > 0 and depth >= max_depth: continue callers = get_direct_callers(current_ea) if not callers: continue resolved_callers = set() for c_ea in callers: resolved_ea, _ = resolve_thunk(c_ea) resolved_callers.add(resolved_ea) all_nodes.add(resolved_ea) if resolved_ea not in depth_map: depth_map[resolved_ea] = depth + 1 if resolved_ea not in visited_for_expansion: queue.append((resolved_ea, depth + 1)) if resolved_callers: graph[current_ea] = resolved_callers return graph, all_nodes, depth_map # ============================================================ # 输出格式化(通用,支持方向参数) # ============================================================ def _dir_label(direction): """返回方向描述字符串""" if direction == DIR_CALLERS: return "callers" return "callees" def _edge_label(direction): """返回边关系描述""" if direction == DIR_CALLERS: return "called by" return "calls" def _child_label(direction): """返回子节点含义""" if direction == DIR_CALLERS: return "callers" return "calls" def format_tree(func_ea, graph, direction=DIR_CALLEES): """生成树形文本""" lines = [] root_name = get_func_name_clean(func_ea) children = graph.get(func_ea, set()) is_ext = is_library_or_external(func_ea) ext_tag = " [EXT]" if is_ext else "" child_lbl = _child_label(direction) lines.append("%s (0x%X) [%s %d]%s" % ( root_name, func_ea, child_lbl, len(children), ext_tag)) printed = set() def _walk(node_ea, chain, is_last_list): chain = chain | {node_ea} children = sorted(graph.get(node_ea, set()), key=lambda ea: get_func_name_clean(ea)) for i, c_ea in enumerate(children): is_last = (i == len(children) - 1) c_name = get_func_name_clean(c_ea) c_children = graph.get(c_ea, set()) c_is_ext = is_library_or_external(c_ea) prefix = "" for _, last in enumerate(is_last_list): prefix += " " if last else "│ " connector = "└── " if is_last else "├── " ext_tag = " [EXT]" if c_is_ext else "" if c_ea in chain: lines.append("%s%s%s (0x%X) ◄ RECURSIVE%s" % ( prefix, connector, c_name, c_ea, ext_tag)) continue if c_ea in printed and c_children: lines.append("%s%s%s (0x%X) [%s %d] ◄ see above%s" % ( prefix, connector, c_name, c_ea, child_lbl, len(c_children), ext_tag)) continue lines.append("%s%s%s (0x%X) [%s %d]%s" % ( prefix, connector, c_name, c_ea, child_lbl, len(c_children), ext_tag)) printed.add(c_ea) if c_children: _walk(c_ea, chain, is_last_list + [is_last]) _walk(func_ea, {func_ea}, []) return "\n".join(lines) def format_flat_bfs(func_ea, graph, all_nodes, direction=DIR_CALLEES): """生成BFS扁平文本""" lines = [] root_name = get_func_name_clean(func_ea) edge_lbl = _edge_label(direction) dir_lbl = _dir_label(direction) lines.append("[Root] %s (0x%X) [direction: %s]" % (root_name, func_ea, dir_lbl)) lines.append("") visited = {func_ea} current_level = {func_ea} level = 0 while current_level: next_level = set() level += 1 for node_ea in sorted(current_level, key=lambda ea: get_func_name_clean(ea)): node_name = get_func_name_clean(node_ea) children = graph.get(node_ea, set()) for c_ea in sorted(children, key=lambda ea: get_func_name_clean(ea)): if c_ea not in visited: c_name = get_func_name_clean(c_ea) ext_tag = " [EXT]" if is_library_or_external(c_ea) else "" lines.append(" Level %d: %s (0x%X) %s --> %s (0x%X)%s" % ( level, node_name, node_ea, edge_lbl, c_name, c_ea, ext_tag)) next_level.add(c_ea) visited.update(next_level) current_level = next_level lines.append("") lines.append("Total unique %s: %d" % (dir_lbl, len(all_nodes) - 1)) return "\n".join(lines) def format_csv(func_ea, graph, all_nodes, direction=DIR_CALLEES): """生成CSV格式""" if direction == DIR_CALLERS: header = "callee_name,callee_addr,caller_name,caller_addr,caller_is_external" else: header = "caller_name,caller_addr,callee_name,callee_addr,callee_is_external" lines = [header] for parent_ea in sorted(graph.keys()): parent_name = get_func_name_clean(parent_ea) for c_ea in sorted(graph[parent_ea]): c_name = get_func_name_clean(c_ea) c_ext = "1" if is_library_or_external(c_ea) else "0" if direction == DIR_CALLERS: # parent是被调用者,child是调用者 lines.append("%s,0x%X,%s,0x%X,%s" % ( parent_name, parent_ea, c_name, c_ea, c_ext)) else: lines.append("%s,0x%X,%s,0x%X,%s" % ( parent_name, parent_ea, c_name, c_ea, c_ext)) return "\n".join(lines) def format_dot(func_ea, graph, all_nodes, direction=DIR_CALLEES): """生成Graphviz DOT格式""" root_name = get_func_name_clean(func_ea) dir_lbl = _dir_label(direction) lines = [] lines.append('digraph %s_chain {' % dir_lbl) lines.append(' rankdir=%s;' % ("BT" if direction == DIR_CALLERS else "TB")) lines.append(' label="Direction: %s";' % dir_lbl) lines.append(' labelloc=t;') lines.append(' node [shape=box, style=filled, fillcolor=lightyellow, ' 'fontname="Consolas", fontsize=10];') lines.append(' edge [color=gray40, arrowsize=0.7];') lines.append('') # 根节点高亮 lines.append(' "%s\\n0x%X" [fillcolor=lightcoral, style=filled, ' 'penwidth=2];' % (root_name, func_ea)) for n_ea in sorted(all_nodes): if n_ea == func_ea: continue n_name = get_func_name_clean(n_ea) if is_library_or_external(n_ea): lines.append(' "%s\\n0x%X" [fillcolor=lightgray, style=filled, ' 'shape=ellipse];' % (n_name, n_ea)) elif n_ea not in graph: lines.append(' "%s\\n0x%X" [fillcolor=lightblue, ' 'style=filled];' % (n_name, n_ea)) lines.append('') for parent_ea in sorted(graph.keys()): parent_name = get_func_name_clean(parent_ea) for c_ea in sorted(graph[parent_ea]): c_name = get_func_name_clean(c_ea) if direction == DIR_CALLERS: # child调用parent: child -> parent lines.append(' "%s\\n0x%X" -> "%s\\n0x%X";' % ( c_name, c_ea, parent_name, parent_ea)) else: # parent调用child: parent -> child lines.append(' "%s\\n0x%X" -> "%s\\n0x%X";' % ( parent_name, parent_ea, c_name, c_ea)) lines.append('}') return "\n".join(lines) def format_json(func_ea, graph, all_nodes, direction=DIR_CALLEES): """生成JSON格式""" root_name = get_func_name_clean(func_ea) dir_lbl = _dir_label(direction) data = { "direction": dir_lbl, "root": { "name": root_name, "address": "0x%X" % func_ea }, "total_nodes": len(all_nodes), "edges": [], "nodes": [] } for parent_ea in sorted(graph.keys()): parent_name = get_func_name_clean(parent_ea) for c_ea in sorted(graph[parent_ea]): c_name = get_func_name_clean(c_ea) if direction == DIR_CALLERS: data["edges"].append({ "caller": {"name": c_name, "address": "0x%X" % c_ea}, "callee": {"name": parent_name, "address": "0x%X" % parent_ea} }) else: data["edges"].append({ "caller": {"name": parent_name, "address": "0x%X" % parent_ea}, "callee": {"name": c_name, "address": "0x%X" % c_ea} }) for n_ea in sorted(all_nodes): n_name = get_func_name_clean(n_ea) n_children = graph.get(n_ea, set()) data["nodes"].append({ "name": n_name, "address": "0x%X" % n_ea, "connections": len(n_children), "is_root": (n_ea == func_ea), "is_external": is_library_or_external(n_ea) }) return json.dumps(data, indent=2, ensure_ascii=False) def format_markdown(func_ea, graph, all_nodes, direction=DIR_CALLEES): """生成Markdown格式""" root_name = get_func_name_clean(func_ea) dir_lbl = _dir_label(direction) lines = [] lines.append("# Call Chain (%s): `%s`" % (dir_lbl, root_name)) lines.append("") lines.append("**Direction:** %s" % dir_lbl) lines.append("") lines.append("**Root:** `%s` (`0x%X`)" % (root_name, func_ea)) lines.append("") lines.append("**Total functions:** %d" % len(all_nodes)) lines.append("") ext_count = sum(1 for n in all_nodes if is_library_or_external(n)) int_count = len(all_nodes) - ext_count lines.append("**Internal:** %d | **External/Library:** %d" % (int_count, ext_count)) lines.append("") if direction == DIR_CALLERS: lines.append("## Call Graph (caller → callee)") lines.append("") lines.append("| Caller | Addr | → | Callee | Addr | External |") lines.append("|--------|------|---|--------|------|----------|") for parent_ea in sorted(graph.keys()): parent_name = get_func_name_clean(parent_ea) for c_ea in sorted(graph[parent_ea]): c_name = get_func_name_clean(c_ea) c_ext = "✓" if is_library_or_external(c_ea) else "" lines.append("| `%s` | `0x%X` | → | `%s` | `0x%X` | %s |" % ( c_name, c_ea, parent_name, parent_ea, c_ext)) else: lines.append("## Call Graph (caller → callee)") lines.append("") lines.append("| Caller | Addr | → | Callee | Addr | External |") lines.append("|--------|------|---|--------|------|----------|") for parent_ea in sorted(graph.keys()): parent_name = get_func_name_clean(parent_ea) for c_ea in sorted(graph[parent_ea]): c_name = get_func_name_clean(c_ea) c_ext = "✓" if is_library_or_external(c_ea) else "" lines.append("| `%s` | `0x%X` | → | `%s` | `0x%X` | %s |" % ( parent_name, parent_ea, c_name, c_ea, c_ext)) lines.append("") lines.append("## Tree View") lines.append("") lines.append("```") lines.append(format_tree(func_ea, graph, direction)) lines.append("```") return "\n".join(lines) # ============================================================ # GUI对话框 # ============================================================ class CallChainForm(ida_kernwin.Form): """可视化输入对话框""" def __init__(self): idb_path = idc.get_idb_path() if idb_path: default_dir = os.path.dirname(idb_path) else: default_dir = os.path.expanduser("~") cur_ea = idc.get_screen_ea() cur_func = idaapi.get_func(cur_ea) default_name = "" if cur_func: default_name = get_func_name_clean(cur_func.start_ea) form_str = r"""STARTITEM 0 BUTTON YES* Analyze BUTTON CANCEL Cancel Call Chain Analyzer <##Function Name\::{txtFuncName}> <##Max Depth (0=unlimited)\::{intMaxDepth}> <##Export Directory\::{txtExportDir}> Analysis Direction: <Callees (who does this function call):{rCallees}> <Callers (who calls this function):{rCallers}> <Both directions:{rBoth}>{rgDirection}> Export Format: <Tree (txt):{rTree}> <Flat BFS (txt):{rFlat}> <CSV:{rCSV}> <Graphviz DOT:{rDOT}> <JSON:{rJSON}> <Markdown:{rMarkdown}> <All Formats:{rAll}>{rgFormat}> Options: <Print to console:{cPrint}> <Export to file:{cExport}>{cgOptions}> """ ida_kernwin.Form.__init__(self, form_str, { 'txtFuncName': ida_kernwin.Form.StringInput(value=default_name), 'intMaxDepth': ida_kernwin.Form.NumericInput( value=0, tp=ida_kernwin.Form.FT_DEC), 'txtExportDir': ida_kernwin.Form.DirInput(value=default_dir), 'rgDirection': ida_kernwin.Form.RadGroupControl( ("rCallees", "rCallers", "rBoth")), 'rgFormat': ida_kernwin.Form.RadGroupControl( ("rTree", "rFlat", "rCSV", "rDOT", "rJSON", "rMarkdown", "rAll")), 'cgOptions': ida_kernwin.Form.ChkGroupControl( ("cPrint", "cExport"), value=3), }) def get_export_configs(format_idx, func_ea, graph, all_nodes, direction): """根据格式索引返回 [(后缀, 生成函数), ...]""" dir_tag = "callers" if direction == DIR_CALLERS else "callees" formatters = { 0: [(".%s.tree.txt" % dir_tag, lambda: format_tree(func_ea, graph, direction))], 1: [(".%s.bfs.txt" % dir_tag, lambda: format_flat_bfs(func_ea, graph, all_nodes, direction))], 2: [(".%s.csv" % dir_tag, lambda: format_csv(func_ea, graph, all_nodes, direction))], 3: [(".%s.dot" % dir_tag, lambda: format_dot(func_ea, graph, all_nodes, direction))], 4: [(".%s.json" % dir_tag, lambda: format_json(func_ea, graph, all_nodes, direction))], 5: [(".%s.md" % dir_tag, lambda: format_markdown(func_ea, graph, all_nodes, direction))], } if format_idx == 6: # All result = [] for fmt_list in formatters.values(): result.extend(fmt_list) return result else: return formatters.get(format_idx, formatters[0]) # ============================================================ # 主入口 # ============================================================ def run_call_chain_analysis(): """主入口:弹出GUI对话框进行分析""" form = CallChainForm() form.Compile() ok = form.Execute() if ok != 1: form.Free() return func_name = form.txtFuncName.value.strip() max_depth = form.intMaxDepth.value export_dir = form.txtExportDir.value.strip() direction = form.rgDirection.value # 0=callees, 1=callers, 2=both format_idx = form.rgFormat.value do_print = bool(form.cgOptions.value & 1) do_export = bool(form.cgOptions.value & 2) form.Free() if not func_name: ida_kernwin.warning("Please enter a function name!") return if direction == DIR_BOTH: # 两个方向都执行 _do_analysis(func_name, max_depth, export_dir, format_idx, do_print, do_export, DIR_CALLEES) print("") print("=" * 80) print("") _do_analysis(func_name, max_depth, export_dir, format_idx, do_print, do_export, DIR_CALLERS) else: _do_analysis(func_name, max_depth, export_dir, format_idx, do_print, do_export, direction) def _find_func_ea(func_name): """根据名称查找函数地址,支持多种前缀尝试""" func_ea = idc.get_name_ea_simple(func_name) if func_ea != idaapi.BADADDR: return func_ea for prefix in ['_', '__', 'j_', '__imp_', '_imp_']: test_ea = idc.get_name_ea_simple(prefix + func_name) if test_ea != idaapi.BADADDR: return test_ea return idaapi.BADADDR def _do_analysis(func_name, max_depth, export_dir, format_idx, do_print, do_export, direction): """执行单方向分析核心逻辑""" dir_lbl = _dir_label(direction) # 查找函数 func_ea = _find_func_ea(func_name) if func_ea == idaapi.BADADDR: msg = "Function '%s' not found!" % func_name print("[ERROR] " + msg) ida_kernwin.warning(msg) return # 解析thunk original_ea = func_ea func_ea, resolved_name = resolve_thunk(func_ea) if func_ea != original_ea: print("[INFO] '%s' is a thunk -> resolved to '%s' (0x%X)" % ( func_name, resolved_name, func_ea)) func_name = resolved_name # 构建调用图 depth_str = ("max depth %d" % max_depth) if max_depth > 0 else "unlimited depth" print("[*] Building %s graph for '%s' (0x%X), %s ..." % ( dir_lbl, func_name, func_ea, depth_str)) try: ida_kernwin.show_wait_box( "Analyzing %s for '%s'..." % (dir_lbl, func_name)) if direction == DIR_CALLERS: graph, all_nodes, depth_map = build_caller_graph(func_ea, max_depth) else: graph, all_nodes, depth_map = build_callee_graph(func_ea, max_depth) ida_kernwin.hide_wait_box() except Exception as e: ida_kernwin.hide_wait_box() ida_kernwin.warning("Error: %s" % str(e)) import traceback traceback.print_exc() return total_related = len(all_nodes) - 1 total_edges = sum(len(v) for v in graph.values()) ext_count = sum(1 for n in all_nodes if is_library_or_external(n)) max_d = max(depth_map.values()) if depth_map else 0 print("[*] Done [%s]. %d functions, %d edges, max depth %d, %d external." % ( dir_lbl, total_related, total_edges, max_d, ext_count)) if total_related == 0: ida_kernwin.info("No %s found for '%s'." % (dir_lbl, func_name)) return # 头部信息 header = "=" * 80 + "\n" header += "Call Chain Analysis [%s]\n" % dir_lbl.upper() header += "Root: %s (0x%X)\n" % (func_name, func_ea) header += "Direction: %s\n" % dir_lbl header += "Functions: %d (internal: %d, external: %d)\n" % ( total_related, total_related - ext_count + 1, ext_count) header += "Edges: %d\n" % total_edges header += "Max Depth: %d\n" % max_d header += "IDB: %s\n" % os.path.basename(idc.get_idb_path() or "unknown") header += "=" * 80 # 打印到控制台 if do_print: print(header) print("") tree_text = format_tree(func_ea, graph, direction) print(tree_text) print("") print("Total unique functions: %d" % len(all_nodes)) print("=" * 80) # 导出到文件 if do_export: if not export_dir or not os.path.isdir(export_dir): ida_kernwin.warning("Invalid export directory: %s" % export_dir) return safe_name = "".join( c if c.isalnum() or c in ('_', '-') else '_' for c in func_name) configs = get_export_configs( format_idx, func_ea, graph, all_nodes, direction) exported_files = [] for suffix, gen_func in configs: filename = "chain_%s%s" % (safe_name, suffix) filepath = os.path.join(export_dir, filename) try: content = gen_func() full_content = header + "\n\n" + content with open(filepath, 'w', encoding='utf-8') as f: f.write(full_content) exported_files.append(filepath) print("[+] Exported: %s" % filepath) except Exception as e: print("[!] Failed to export %s: %s" % (filepath, str(e))) if exported_files: msg = "Exported %d file(s) [%s]:\n\n" % (len(exported_files), dir_lbl) for fp in exported_files: msg += " %s\n" % fp ida_kernwin.info(msg) # ============================================================ # 命令行快捷接口 # ============================================================ def analyze(func_name, export_path=None, fmt="tree", max_depth=0, direction="callees"): """ 命令行快捷接口。 参数: func_name: 函数名称 export_path: 导出路径 (None=仅打印) fmt: "tree" | "flat" | "csv" | "dot" | "json" | "md" max_depth: 最大深度, 0=无限 direction: "callees" | "callers" | "both" 用法: analyze("main") analyze("main", direction="callers") analyze("sub_401000", "/tmp/out.dot", "dot", direction="both") analyze("WinMain", "C:\\out.json", "json", max_depth=5) """ dir_map = {"callees": DIR_CALLEES, "callers": DIR_CALLERS, "both": DIR_BOTH} dir_val = dir_map.get(direction, DIR_CALLEES) directions_to_run = [] if dir_val == DIR_BOTH: directions_to_run = [DIR_CALLEES, DIR_CALLERS] else: directions_to_run = [dir_val] func_ea = _find_func_ea(func_name) if func_ea == idaapi.BADADDR: print("[ERROR] Function '%s' not found!" % func_name) return func_ea, resolved_name = resolve_thunk(func_ea) for d in directions_to_run: dir_lbl = _dir_label(d) print("[*] Analyzing %s of '%s' (0x%X) ..." % ( dir_lbl, resolved_name, func_ea)) if d == DIR_CALLERS: graph, all_nodes, depth_map = build_caller_graph(func_ea, max_depth) else: graph, all_nodes, depth_map = build_callee_graph(func_ea, max_depth) fmt_map = { "tree": lambda: format_tree(func_ea, graph, d), "flat": lambda: format_flat_bfs(func_ea, graph, all_nodes, d), "csv": lambda: format_csv(func_ea, graph, all_nodes, d), "dot": lambda: format_dot(func_ea, graph, all_nodes, d), "json": lambda: format_json(func_ea, graph, all_nodes, d), "md": lambda: format_markdown(func_ea, graph, all_nodes, d), } gen = fmt_map.get(fmt, fmt_map["tree"]) content = gen() print("") print("--- %s ---" % dir_lbl.upper()) print(content) print("\nTotal: %d functions, %d edges" % ( len(all_nodes), sum(len(v) for v in graph.values()))) if export_path: # 双向时自动加后缀区分 if dir_val == DIR_BOTH: base, ext = os.path.splitext(export_path) actual_path = "%s_%s%s" % (base, dir_lbl, ext) else: actual_path = export_path with open(actual_path, 'w', encoding='utf-8') as f: f.write(content) print("[+] Exported to: %s" % actual_path) if len(directions_to_run) > 1: print("") print("=" * 80) print("") # ============================================================ # 运行入口 # ============================================================ if __name__ == "__main__": run_call_chain_analysis() else: run_call_chain_analysis()
以下只是做为归档保留,参考使用

2D图
# -*- coding: utf-8 -*- """ IDA 全量函数调用链有向图导出 深色大节点 平滑拖拽无抖动 邻接表追踪 """ import ida_funcs import ida_xref import ida_name import ida_idaapi import ida_entry import ida_segment import ida_nalt import idc import idautils import json import os import html from collections import defaultdict def get_function_name(ea): name = ida_name.get_name(ea) if not name: name = "sub_{:X}".format(ea) if len(name) > 60: name = name[:30] + "..." + name[-8:] return name def classify_function(ea): flags = idc.get_func_attr(ea, idc.FUNCATTR_FLAGS) if flags == -1: return "normal" if flags & idc.FUNC_LIB: return "library" if flags & idc.FUNC_THUNK: return "thunk" seg = ida_segment.getseg(ea) if seg: seg_name = ida_segment.get_segm_name(seg) if seg_name in ('.plt', '.plt.got', '.got', '__stubs', '__stub_helper', '.idata', '_imp'): return "import" if seg.type == ida_segment.SEG_XTRN: return "import" name = ida_name.get_name(ea) if name and name.startswith(('j', '_imp', 'imp')): return "thunk" return "normal" def find_entry_points(): entries = set() for i in range(ida_entry.get_entry_qty()): ordinal = ida_entry.get_entry_ordinal(i) ea = ida_entry.get_entry(ordinal) if ea != ida_idaapi.BADADDR: func = ida_funcs.get_func(ea) if func: entries.add(func.start_ea) for name in ['main', '_main', 'start', '_start', 'WinMain', 'DllMain', 'JNI_OnLoad', '__libc_start_main', 'entry', '_entry', 'wmain']: ea = ida_name.get_name_ea(ida_idaapi.BADADDR, name) if ea != ida_idaapi.BADADDR: func = ida_funcs.get_func(ea) if func: entries.add(func.start_ea) return entries def build_call_graph(): print("[*] 扫描函数...") nodes = {} edges = [] edge_set = set() all_funcs = list(idautils.Functions()) total = len(all_funcs) print("[*] 共 {} 个函数".format(total)) entry_points = find_entry_points() all_callees = set() for idx, func_ea in enumerate(all_funcs): if idx % 500 == 0: print(" {}/{}".format(idx, total)) func = ida_funcs.get_func(func_ea) if not func: continue func_type = classify_function(func_ea) if func_ea in entry_points: func_type = "entry" if func_ea not in nodes: nodes[func_ea] = {"name": get_function_name(func_ea), "type": func_type, "out": 0, "in": 0, "size": func.size()} for head in idautils.FuncItems(func_ea): for xref in idautils.XrefsFrom(head, 0): if xref.type not in (ida_xref.fl_CF, ida_xref.fl_CN, ida_xref.fl_JF, ida_xref.fl_JN): continue target = xref.to target_func = ida_funcs.get_func(target) if xref.type in (ida_xref.fl_JF, ida_xref.fl_JN): if target_func and target_func.start_ea == func_ea: continue if target_func: target = target_func.start_ea elif not ida_name.get_name(target): continue if target == func_ea: continue all_callees.add(target) if target not in nodes: t_type = classify_function(target) if target in entry_points: t_type = "entry" t_func = ida_funcs.get_func(target) nodes[target] = {"name": get_function_name(target), "type": t_type, "out": 0, "in": 0, "size": t_func.size() if t_func else 0} key = (func_ea, target) if key not in edge_set: edge_set.add(key) edges.append({"from": func_ea, "to": target}) nodes[func_ea]["out"] += 1 nodes[target]["in"] += 1 for ea in nodes: if ea not in all_callees and nodes[ea]["type"] == "normal": nodes[ea]["type"] = "root" print("[*] {} 节点, {} 边".format(len(nodes), len(edges))) return nodes, edges def generate_html(nodes, edges, output_path): cm = { "entry": {"bg": "#C62828", "lb": u"入口函数"}, "root": {"bg": "#E65100", "lb": u"根函数"}, "normal": {"bg": "#1565C0", "lb": u"普通函数"}, "import": {"bg": "#2E7D32", "lb": u"导入函数"}, "thunk": {"bg": "#F9A825", "lb": u"Thunk"}, "library": {"bg": "#6A1B9A", "lb": u"库函数"}, } ea_to_id = {} vn = [] ve = [] adj_down = defaultdict(list) adj_up = defaultdict(list) for idx, (ea, info) in enumerate(sorted(nodes.items())): ea_to_id[ea] = idx c = cm.get(info["type"], cm["normal"]) size = 30 + min(info["out"] * 3, 40) if info["type"] in ("entry", "root"): size = max(size, 55) short = info["name"] if len(short) > 20: short = short[:17] + "..." label = u"{}\n\u2191{} \u2193{}".format(short, info["in"], info["out"]) fc = "#222" if info["type"] == "thunk" else "#FFF" vn.append({ "id": idx, "label": label, "color": {"background": c["bg"], "border": c["bg"], "highlight": {"background": c["bg"], "border": c["bg"]}}, "shadow": {"enabled": True, "color": "rgba(0,0,0,0.15)", "size": 15, "x": 0, "y": 5}, "shape": "dot", "size": size, "font": {"color": fc, "size": 13, "face": "Arial", "multi": True, "align": "center"}, "borderWidth": 0, "borderWidthSelected": 0, "ntype": info["type"], "ea": "0x{:X}".format(ea), "fullName": info["name"], "outDeg": info["out"], "inDeg": info["in"], "funcSize": info["size"], "typeLabel": c["lb"], "bgColor": c["bg"], "ftColor": fc }) for i, e in enumerate(edges): if e["from"] in ea_to_id and e["to"] in ea_to_id: fid = ea_to_id[e["from"]] tid = ea_to_id[e["to"]] adj_down[fid].append(tid) adj_up[tid].append(fid) ve.append({ "id": i, "from": fid, "to": tid, "arrows": {"to": {"enabled": True, "scaleFactor": 0.5, "type": "vee"}}, "color": {"color": "rgba(100,100,100,0.18)", "highlight": "#C62828"}, "width": 1.2, "smooth": {"type": "cubicBezier", "forceDirection": "none", "roundness": 0.2}, "selectionWidth": 2 }) adj_down_dict = {str(k): v for k, v in adj_down.items()} adj_up_dict = {str(k): v for k, v in adj_up.items()} page = r'''<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Call Graph</title> <script src="https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js"></script> <style> *{margin:0;padding:0;box-sizing:border-box} body{background:#F5F5F5;color:#333;font-family:'Segoe UI',Arial,sans-serif;overflow:hidden} #bar{position:fixed;top:0;left:0;right:0;height:48px;background:#fff;border-bottom:1px solid #E8E8E8; display:flex;align-items:center;padding:0 18px;gap:10px;z-index:100;box-shadow:0 1px 8px rgba(0,0,0,0.04)} .t{color:#C62828;font-size:16px;font-weight:bold;white-space:nowrap} .s{color:#AAA;font-size:11px;white-space:nowrap} #search{background:#F0F0F0;color:#333;border:1px solid #DDD;padding:6px 14px;border-radius:8px;width:220px;font-size:12px} #search:focus{border-color:#1565C0;outline:none;box-shadow:0 0 0 3px rgba(21,101,192,0.12);background:#fff} .b{background:#F0F0F0;color:#555;border:1px solid #DDD;padding:6px 14px;border-radius:8px;cursor:pointer; font-size:11px;white-space:nowrap;transition:all .2s} .b:hover{background:#E3F2FD;border-color:#1565C0;color:#1565C0} .b.on{background:#1565C0;border-color:#1565C0;color:#fff} #graph{position:fixed;top:48px;left:0;right:0;bottom:0;background:#F5F5F5;cursor:grab} #info{position:fixed;bottom:20px;left:20px;background:#fff;border:1px solid #E8E8E8;border-radius:14px; padding:16px 20px;max-width:340px;font-size:12px;z-index:99;display:none;line-height:1.8; box-shadow:0 8px 30px rgba(0,0,0,0.08)} #info .x{position:absolute;top:10px;right:14px;cursor:pointer;color:#ccc;font-size:18px} #info .x:hover{color:#C62828} #legend{position:fixed;top:66px;right:20px;background:rgba(255,255,255,0.98);border:1px solid #E8E8E8; border-radius:14px;padding:14px 18px;font-size:11px;z-index:99;line-height:2.6; box-shadow:0 4px 16px rgba(0,0,0,0.04)} .lr{display:flex;align-items:center;gap:9px} .ld{width:14px;height:14px;border-radius:50%;display:inline-block} #sr{position:fixed;top:48px;left:265px;background:#fff;border:1px solid #E8E8E8;border-radius:0 0 10px 10px; max-height:300px;overflow-y:auto;width:300px;z-index:101;display:none;box-shadow:0 6px 20px rgba(0,0,0,0.06)} .si{padding:7px 14px;cursor:pointer;font-size:11px;border-bottom:1px solid #F5F5F5; white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .si:hover{background:#F5F8FF} .sa{color:#BBB;margin-left:6px} #back{position:fixed;bottom:20px;right:20px;z-index:99;display:none} #mode{position:fixed;top:54px;left:50%;transform:translateX(-50%);background:#C62828;color:#fff; padding:5px 18px;border-radius:0 0 10px 10px;font-size:11px;z-index:99;display:none; box-shadow:0 3px 10px rgba(198,40,40,0.25)} </style> </head> <body> <div id="bar"> <span class="t">◉ Call Graph</span> <span class="s">__TN__ nodes · __TE__ edges</span> <input id="search" placeholder="🔍 搜索..." oninput="doSearch(this.value)"> <button class="b" onclick="fitAll()">适应窗口</button> <button class="b" onclick="resetAll()">显示全部</button> <button class="b" id="bp" onclick="togPhys()">物理引擎</button> <button class="b" onclick="isolateNeighbor()">隔离邻居</button> <button class="b" onclick="expPNG()">导出PNG</button> </div> <div id="sr"></div> <div id="graph"></div> <div id="info"><span class="x" onclick="this.parentElement.style.display='none'">×</span><div id="ic"></div></div> <div id="legend"> <div class="lr"><span class="ld" style="background:#C62828"></span>入口函数</div> <div class="lr"><span class="ld" style="background:#E65100"></span>根函数</div> <div class="lr"><span class="ld" style="background:#1565C0"></span>普通函数</div> <div class="lr"><span class="ld" style="background:#2E7D32"></span>导入函数</div> <div class="lr"><span class="ld" style="background:#F9A825"></span>Thunk</div> <div class="lr"><span class="ld" style="background:#6A1B9A"></span>库函数</div> <div style="margin-top:10px;color:#bbb;font-size:9px;line-height:1.8"> ↑被调用 ↓调用<br>滚轮=缩放<br>左键拖空白=平移<br>左键拖节点=移动<br>单击=详情<br>双击=隔离调用链 </div> </div> <div id="mode"></div> <div id="back"><button class="b" style="padding:10px 24px;font-size:13px;background:#1565C0;color:#fff; border-color:#1565C0;border-radius:10px;box-shadow:0 3px 12px rgba(21,101,192,0.3)" onclick="resetAll()">← 返回全部</button></div> <script> var RN=__NODES__; var RE=__EDGES__; var ADJ_DOWN=__ADJ_DOWN__; var ADJ_UP=__ADJ_UP__; var nodes=new vis.DataSet(RN); var edges=new vis.DataSet(RE); var ctr=document.getElementById('graph'); var isolated=false; var physOn=false; var net=new vis.Network(ctr,{nodes:nodes,edges:edges},{ layout:{improvedLayout:true, hierarchical:false}, physics:{ enabled:true, solver:'forceAtlas2Based', forceAtlas2Based:{ gravitationalConstant:-60, centralGravity:0.002, springLength:180, springConstant:0.006, damping:0.4, avoidOverlap:1.0 }, maxVelocity:25, minVelocity:0.08, stabilization:{enabled:true, iterations:800, updateInterval:10} }, edges:{ smooth:{type:'cubicBezier', forceDirection:'none', roundness:0.2}, color:{inherit:false}, selectionWidth:2 }, nodes:{ shadow:true, borderWidth:0, borderWidthSelected:0, chosen:false }, interaction:{ hover:false, tooltipDelay:999999, dragNodes:true, dragView:false, zoomView:false, keyboard:false, multiselect:false, navigationButtons:false, hideEdgesOnDrag:false, hideEdgesOnZoom:false, hoverConnectedEdges:false } }); /* ============================================================= 自维护视口状态 —— 彻底消除抖动 核心思路:自己维护 viewX, viewY, viewScale 三个变量, 所有平移/缩放操作只修改这三个变量, 然后统一在 rAF 中调用一次 moveTo 同步给 vis。 绝不在 moveTo 之后再读 getViewPosition, 切断 vis 内部状态的反馈回路。 ============================================================= */ var view = {x:0, y:0, scale:1, dirty:false, rafId:0}; /* 从 vis 同步一次初始状态 */ function syncFromVis(){ var vp = net.getViewPosition(); view.x = vp.x; view.y = vp.y; view.scale = net.getScale(); } /* 唯一的渲染入口:rAF 驱动 */ function viewCommit(){ if(!view.dirty){ view.rafId = 0; return; } net.moveTo({ position: {x: view.x, y: view.y}, scale: view.scale, animation: false }); view.dirty = false; view.rafId = 0; } function viewSchedule(){ if(!view.rafId){ view.rafId = requestAnimationFrame(viewCommit); } } /* ===== 平移状态 ===== */ var pan = {active:false, sx:0, sy:0, svx:0, svy:0}; ctr.addEventListener('mousedown', function(e){ if(e.button !== 0) return; var hit = net.getNodeAt({x:e.offsetX, y:e.offsetY}); if(hit !== undefined) return; syncFromVis(); pan.active = true; pan.sx = e.clientX; pan.sy = e.clientY; pan.svx = view.x; pan.svy = view.y; ctr.style.cursor = 'grabbing'; e.preventDefault(); e.stopPropagation(); }, true); window.addEventListener('mousemove', function(e){ if(!pan.active) return; var dx = e.clientX - pan.sx; var dy = e.clientY - pan.sy; view.x = pan.svx - dx / view.scale; view.y = pan.svy - dy / view.scale; view.dirty = true; viewSchedule(); }, true); window.addEventListener('mouseup', function(e){ if(!pan.active) return; /* 最终精确提交 */ var dx = e.clientX - pan.sx; var dy = e.clientY - pan.sy; view.x = pan.svx - dx / view.scale; view.y = pan.svy - dy / view.scale; if(view.rafId){ cancelAnimationFrame(view.rafId); view.rafId = 0; } net.moveTo({ position: {x: view.x, y: view.y}, scale: view.scale, animation: false }); view.dirty = false; pan.active = false; ctr.style.cursor = 'grab'; }, true); /* ===== 滚轮缩放 ===== */ ctr.addEventListener('wheel', function(e){ e.preventDefault(); e.stopPropagation(); syncFromVis(); var factor = e.deltaY < 0 ? 1.15 : 1/1.15; var newScale = view.scale * factor; if(newScale < 0.003) newScale = 0.003; if(newScale > 30) newScale = 30; /* 以鼠标位置为中心 */ var pointer = net.DOMtoCanvas({x:e.offsetX, y:e.offsetY}); var r = view.scale / newScale; view.x = pointer.x - (pointer.x - view.x) * r; view.y = pointer.y - (pointer.y - view.y) * r; view.scale = newScale; /* 缩放立即提交,不延迟 */ if(view.rafId){ cancelAnimationFrame(view.rafId); view.rafId = 0; } net.moveTo({ position: {x: view.x, y: view.y}, scale: view.scale, animation: false }); view.dirty = false; },{passive:false}); /* ===== 稳定后关闭物理 ===== */ net.on('stabilizationIterationsDone', function(){ net.setOptions({physics:{enabled:false}}); physOn = false; document.getElementById('bp').classList.remove('on'); net.fit({animation:{duration:600}}); }); /* ===== 拖节点 ===== */ net.on('dragStart', function(p){ if(p.nodes.length > 0) net.setOptions({physics:{enabled:false}}); }); net.on('dragEnd', function(p){ if(p.nodes.length > 0 && physOn) net.setOptions({physics:{enabled:true}}); }); /* ===== 邻接表追踪 ===== */ function traceDown(id,lim){ var s=new Set([id]),q=[id]; while(q.length){var c=q.shift();(ADJ_DOWN[c]||[]).forEach(function(n){ if(!s.has(n)){s.add(n);if(s.size<lim)q.push(n);}});}return s;} function traceUp(id,lim){ var s=new Set([id]),q=[id]; while(q.length){var c=q.shift();(ADJ_UP[c]||[]).forEach(function(n){ if(!s.has(n)){s.add(n);if(s.size<lim)q.push(n);}});}return s;} function traceAll(id,lim){ var s=traceUp(id,lim);traceDown(id,lim).forEach(function(v){s.add(v);});return s;} function showInfo(id){ var n=nodes.get(id);if(!n)return; document.getElementById('ic').innerHTML= "<div style='font-family:monospace;line-height:2.1'>" +"<b style='color:"+n.bgColor+";font-size:15px'>● "+esc(n.fullName)+"</b><br>" +"<span style='color:#999'>地址</span> "+n.ea+"<br>" +"<span style='color:#999'>类型</span> "+n.typeLabel+"<br>" +"<span style='color:#999'>大小</span> "+n.funcSize+" bytes<br>" +"<div style='border-top:1px solid #EEE;margin:8px 0'></div>" +"<span style='color:#999'>被调用 ↑</span> <b style='font-size:16px;color:"+n.bgColor+"'>"+n.inDeg+"</b><br>" +"<span style='color:#999'>调用 ↓</span> <b style='font-size:16px;color:"+n.bgColor+"'>"+n.outDeg+"</b></div>"; document.getElementById('info').style.display='block'; } net.on('click', function(p){if(p.nodes.length)showInfo(p.nodes[0]);}); net.on('doubleClick', function(p){ if(!p.nodes.length)return; var id=p.nodes[0]; if(isolated){ net.focus(id,{scale:1.5, animation:{duration:300}}); net.selectNodes([id]); showInfo(id); }else{isoChain(id);} }); function isoChain(id){ var keep=traceAll(id,500); applyFilter(keep); isolated=true; net.selectNodes([id]); var n=nodes.get(id); var ml=document.getElementById('mode'); ml.textContent='调用链: '+(n?n.fullName:'#'+id)+' ('+keep.size+'个节点)'; ml.style.display='block'; document.getElementById('back').style.display='block'; setTimeout(function(){net.fit({animation:{duration:400}});},200); } function applyFilter(keep){ nodes.update(RN.map(function(n){return{id:n.id, hidden:!keep.has(n.id)};})); edges.update(RE.map(function(e){return{id:e.id, hidden:!(keep.has(e.from)&&keep.has(e.to))};})); } function resetAll(){ nodes.update(RN.map(function(n){return{id:n.id, hidden:false};})); edges.update(RE.map(function(e){return{id:e.id, hidden:false};})); net.unselectAll(); isolated=false; document.getElementById('back').style.display='none'; document.getElementById('info').style.display='none'; document.getElementById('mode').style.display='none'; net.fit({animation:{duration:400}}); } function fitAll(){net.fit({animation:{duration:400}});} function togPhys(){ var b=document.getElementById('bp'); if(physOn){ net.setOptions({physics:{enabled:false}}); physOn=false; b.classList.remove('on'); }else{ net.setOptions({physics:{enabled:true, solver:'barnesHut', barnesHut:{gravitationalConstant:-800, centralGravity:0.003, springLength:200, springConstant:0.004, damping:0.9, avoidOverlap:1.0}, maxVelocity:5, minVelocity:0.01, timestep:0.3}}); physOn=true; b.classList.add('on'); } } function isolateNeighbor(){ var s=net.getSelectedNodes(); if(!s.length){alert('请先选择节点');return;} var keep=new Set(s); s.forEach(function(id){ (ADJ_DOWN[id]||[]).forEach(function(n){keep.add(n);}); (ADJ_UP[id]||[]).forEach(function(n){keep.add(n);}); }); applyFilter(keep); isolated=true; document.getElementById('back').style.display='block'; var n=nodes.get(s[0]); var ml=document.getElementById('mode'); ml.textContent='邻居: '+(n?n.fullName:''); ml.style.display='block'; setTimeout(function(){net.fit({animation:{duration:300}});},150); } function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML;} function doSearch(q){ var box=document.getElementById('sr'); if(!q||q.length<2){box.style.display='none';return;} q=q.toLowerCase(); var m=RN.filter(function(n){ return n.fullName.toLowerCase().indexOf(q)>=0 || n.ea.toLowerCase().indexOf(q)>=0; }).slice(0,30); if(!m.length){box.style.display='none';return;} box.innerHTML=''; m.forEach(function(n){ var d=document.createElement('div'); d.className='si'; d.innerHTML='<span style="color:'+n.color.background+'">●</span> '+esc(n.fullName) +' <span class="sa">'+n.ea+' ↑'+n.inDeg+' ↓'+n.outDeg+'</span>'; d.onclick=function(){ box.style.display='none'; document.getElementById('search').value=''; if(isolated) resetAll(); setTimeout(function(){ net.focus(n.id,{scale:1.8, animation:true}); net.selectNodes([n.id]); }, isolated?500:50); }; box.appendChild(d); }); box.style.display='block'; } document.addEventListener('click',function(e){ if(e.target.id!=='search') document.getElementById('sr').style.display='none'; }); function expPNG(){ var c=ctr.getElementsByTagName('canvas')[0]; if(!c)return; var a=document.createElement('a'); a.download='callgraph.png'; a.href=c.toDataURL('image/png'); a.click(); } </script> </body> </html>''' page = page.replace('__NODES__', json.dumps(vn, ensure_ascii=False)) page = page.replace('__EDGES__', json.dumps(ve, ensure_ascii=False)) page = page.replace('__ADJ_DOWN__', json.dumps(adj_down_dict, ensure_ascii=False)) page = page.replace('__ADJ_UP__', json.dumps(adj_up_dict, ensure_ascii=False)) page = page.replace('__TN__', str(len(vn))) page = page.replace('__TE__', str(len(ve))) with open(output_path, 'w', encoding='utf-8') as f: f.write(page) print("[+] 已导出: {}".format(output_path)) print("=" * 50) print(" IDA Call Graph Exporter") print("=" * 50) _nodes, _edges = build_call_graph() if not _nodes: print("[!] 未找到函数") else: _inp = ida_nalt.get_input_file_path() or "" _dir = os.path.dirname(_inp) if _inp else os.path.expanduser("~") _bin = ida_nalt.get_root_filename() or "binary" _out = os.path.join(_dir, "{}_callgraph.html".format(os.path.splitext(_bin)[0])) generate_html(_nodes, _edges, _out) _ty = defaultdict(int) for _n in _nodes.values(): _ty[_n["type"]] += 1 print("\n节点类型:") for t, c in sorted(_ty.items(), key=lambda x: -x[1]): print(" {:>10}: {}".format(t, c)) print("\n浏览器打开: {}".format(_out))
下面代码仅做参考
2D图
# -*- coding: utf-8 -*-
"""
IDA Function Call Graph - 2D Disk Sphere Visualization (Enhanced Call Chain)
功能:
- 导出所有函数控制流(调用关系)
- 统计每个函数被调用次数,不同颜色节点
- Fibonacci Sunflower 圆盘布局(热点函数居中)
- 鼠标滚轮缩放、左键拖拽、悬停高亮、搜索
- 【新增】完整调用链展示:双击节点递归展开上下游调用链
- 【新增】调用链树形面板、深度控制、链路动画
- 【新增】右键菜单:隔离子图、复制路径
"""
import idautils
import idaapi
import idc
import json
import os
import math
import webbrowser
from collections import defaultdict
def extract_call_graph():
"""从IDA提取函数调用图"""
functions = {}
call_count = defaultdict(int)
edge_set = defaultdict(int)
for func_ea in idautils.Functions():
name = idc.get_func_name(func_ea)
if name:
functions[func_ea] = name
call_count[func_ea] = 0
print(f"[*] Found {len(functions)} functions")
done = 0
for caller_ea in list(functions.keys()):
func = idaapi.get_func(caller_ea)
if not func:
continue
for head in idautils.Heads(func.start_ea, func.end_ea):
for xref in idautils.XrefsFrom(head, 0):
if xref.type in (idaapi.fl_CF, idaapi.fl_CN):
cf = idaapi.get_func(xref.to)
if cf and cf.start_ea in functions and cf.start_ea != caller_ea:
edge_set[(caller_ea, cf.start_ea)] += 1
call_count[cf.start_ea] += 1
done += 1
if done % 500 == 0:
print(f" Scanned {done}/{len(functions)}...")
# XrefsTo 补充
for ea in functions:
xc = sum(1 for x in idautils.XrefsTo(ea, 0)
if x.type in (idaapi.fl_CF, idaapi.fl_CN))
call_count[ea] = max(call_count[ea], xc)
edges = [(s, d, w) for (s, d), w in edge_set.items()]
return functions, edges, dict(call_count)
def sunflower_layout(n, radius=600):
"""Fibonacci Sunflower 均匀圆盘布局"""
if n == 0:
return []
if n == 1:
return [(0.0, 0.0)]
golden_angle = math.pi * (3.0 - math.sqrt(5.0))
pts = []
for i in range(n):
r = radius * math.sqrt((i + 0.5) / n)
theta = i * golden_angle
pts.append((round(r * math.cos(theta), 2),
round(r * math.sin(theta), 2)))
return pts
def build_html(nodes_json, edges_json, n_nodes, n_edges, max_cc, layout_r):
"""生成完整 HTML - 增强版:支持完整调用链展示"""
return '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>IDA 2D Function Call Graph - Call Chain Explorer</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;overflow:hidden;font-family:'Segoe UI',Consolas,monospace;color:#c9d1d9}
canvas{display:block;cursor:grab}
canvas.dragging{cursor:grabbing}
#panel{position:absolute;top:10px;left:10px;width:300px;background:rgba(13,17,23,.95);
border:1px solid #30363d;border-radius:10px;padding:14px;z-index:100;
backdrop-filter:blur(8px);user-select:none;max-height:calc(100vh - 20px);overflow-y:auto}
#panel::-webkit-scrollbar{width:4px}
#panel::-webkit-scrollbar-thumb{background:#30363d;border-radius:2px}
#panel h2{font-size:14px;color:#58a6ff;margin-bottom:6px}
.st{font-size:11px;color:#8b949e;margin:2px 0}.st b{color:#c9d1d9}
#hover-info{display:none;margin-top:8px;padding-top:8px;border-top:1px solid #30363d}
.hi-n{color:#58a6ff;font-size:13px;font-weight:bold;word-break:break-all}
.hi-a{color:#8b949e;font-size:11px}
.hi-c{color:#f0883e;font-size:12px;margin-top:2px}
.hi-d{color:#6e7681;font-size:10px;margin-top:2px}
/* 调用链面板 */
#chain-panel{display:none;margin-top:10px;padding-top:10px;border-top:1px solid #30363d}
#chain-panel h3{font-size:12px;color:#f0883e;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between}
#chain-panel h3 span.close-chain{cursor:pointer;color:#8b949e;font-size:14px}
#chain-panel h3 span.close-chain:hover{color:#da3633}
.chain-section{margin-bottom:8px}
.chain-section-title{font-size:11px;color:#58a6ff;margin-bottom:3px;cursor:pointer;user-select:none}
.chain-section-title:hover{color:#79c0ff}
.chain-tree{font-size:10px;line-height:1.8;max-height:200px;overflow-y:auto;padding-left:4px}
.chain-tree::-webkit-scrollbar{width:3px}
.chain-tree::-webkit-scrollbar-thumb{background:#30363d;border-radius:2px}
.chain-item{cursor:pointer;padding:1px 4px;border-radius:3px;white-space:nowrap;display:flex;align-items:center}
.chain-item:hover{background:rgba(56,139,253,0.15)}
.chain-item .depth-bar{display:inline-block;margin-right:2px;color:#484f58}
.chain-item .ci-name{color:#c9d1d9;flex:1;overflow:hidden;text-overflow:ellipsis}
.chain-item .ci-cc{color:#f0883e;font-size:9px;margin-left:4px;flex-shrink:0}
.chain-item.ci-root{color:#f0883e;font-weight:bold}
.chain-item.ci-root .ci-name{color:#f0883e}
.chain-item.ci-caller .ci-name{color:#79c0ff}
.chain-item.ci-callee .ci-name{color:#7ee787}
.chain-stats{font-size:10px;color:#8b949e;margin-top:4px}
/* 深度控制 */
.depth-ctrl{display:flex;align-items:center;gap:6px;margin-top:6px;margin-bottom:6px}
.depth-ctrl label{font-size:10px;color:#8b949e;flex-shrink:0}
.depth-ctrl input[type=range]{flex:1;accent-color:#58a6ff;height:4px}
.depth-ctrl .dv{font-size:11px;color:#58a6ff;width:20px;text-align:center}
.hint{font-size:10px;color:#484f58;margin-top:8px;line-height:1.5}
#search-box{position:absolute;top:10px;right:10px;z-index:100}
#search-box input{width:240px;padding:8px 12px;border-radius:8px;
background:rgba(13,17,23,.93);border:1px solid #30363d;color:#c9d1d9;
font-size:12px;outline:none;backdrop-filter:blur(8px)}
#search-box input:focus{border-color:#58a6ff}
#search-box input::placeholder{color:#484f58}
#legend{position:absolute;bottom:10px;left:10px;background:rgba(13,17,23,.93);
border:1px solid #30363d;border-radius:10px;padding:12px;z-index:100;
backdrop-filter:blur(8px);user-select:none}
#legend h3{font-size:11px;color:#58a6ff;margin-bottom:5px}
.lr{display:flex;align-items:center;margin:2px 0;font-size:10px}
.ld{width:10px;height:10px;border-radius:50%;margin-right:8px;flex-shrink:0}
#btns{position:absolute;bottom:10px;right:10px;z-index:100;display:flex;gap:5px;flex-wrap:wrap;justify-content:flex-end;max-width:500px}
#btns button{padding:7px 11px;border-radius:6px;background:rgba(13,17,23,.93);
border:1px solid #30363d;color:#c9d1d9;cursor:pointer;font-size:11px;
backdrop-filter:blur(8px);white-space:nowrap}
#btns button:hover{border-color:#58a6ff;color:#58a6ff}
#btns button.active{border-color:#f0883e;color:#f0883e}
/* 右键菜单 */
#ctx-menu{display:none;position:absolute;z-index:300;background:rgba(13,17,23,.97);
border:1px solid #30363d;border-radius:8px;padding:4px 0;min-width:200px;
backdrop-filter:blur(12px);box-shadow:0 8px 24px rgba(0,0,0,.4)}
#ctx-menu .cm-item{padding:7px 16px;font-size:11px;cursor:pointer;display:flex;align-items:center;gap:8px}
#ctx-menu .cm-item:hover{background:rgba(56,139,253,0.15)}
#ctx-menu .cm-sep{height:1px;background:#30363d;margin:4px 0}
#ctx-menu .cm-item .cm-icon{width:16px;text-align:center}
/* 调用链模式指示器 */
#chain-mode-bar{display:none;position:absolute;top:10px;left:50%;transform:translateX(-50%);
z-index:150;background:rgba(240,136,62,.15);border:1px solid #f0883e;border-radius:8px;
padding:6px 16px;font-size:11px;color:#f0883e;backdrop-filter:blur(8px);
display:flex;align-items:center;gap:8px}
#chain-mode-bar .cmb-text{flex:1}
#chain-mode-bar .cmb-close{cursor:pointer;font-size:14px;color:#f0883e}
#chain-mode-bar .cmb-close:hover{color:#da3633}
</style>
</head>
<body>
<canvas id="cv"></canvas>
<div id="panel">
<h2>📊 Call Graph Explorer</h2>
<div class="st">Functions: <b>''' + str(n_nodes) + '''</b></div>
<div class="st">Call Edges: <b>''' + str(n_edges) + '''</b></div>
<div class="st">Max Called: <b>''' + str(max_cc) + '''</b></div>
<div id="hover-info">
<div class="hi-n" id="h-name"></div>
<div class="hi-a" id="h-addr"></div>
<div class="hi-c" id="h-cc"></div>
<div class="hi-d" id="h-out"></div>
<div class="hi-d" id="h-in"></div>
</div>
<div id="chain-panel">
<h3>
<span>🔗 Call Chain</span>
<span class="close-chain" onclick="clearChain()" title="Close">✕</span>
</h3>
<div class="depth-ctrl">
<label>Depth:</label>
<input type="range" id="depth-slider" min="1" max="20" value="8">
<span class="dv" id="depth-val">8</span>
</div>
<div class="chain-section">
<div class="chain-section-title" id="caller-title" onclick="toggleSection('callers')">▼ Callers (upstream)</div>
<div class="chain-tree" id="caller-tree"></div>
</div>
<div class="chain-section">
<div class="chain-section-title" id="callee-title" onclick="toggleSection('callees')">▼ Callees (downstream)</div>
<div class="chain-tree" id="callee-tree"></div>
</div>
<div class="chain-stats" id="chain-stats"></div>
</div>
<div class="hint">
🖱 Drag to pan 🔍 Scroll to zoom<br>
👉 <b>Double-click</b>: show full call chain<br>
👉 <b>Right-click</b>: context menu<br>
⌨ Search box to filter
</div>
</div>
<div id="search-box">
<input id="sch" type="text" placeholder="🔍 Search function name...">
</div>
<div id="legend">
<h3>Call Count Legend</h3>
<div class="lr"><div class="ld" style="background:#3572A5"></div>0 (leaf)</div>
<div class="lr"><div class="ld" style="background:#2188ff"></div>1 ~ 3</div>
<div class="lr"><div class="ld" style="background:#3fb950"></div>4 ~ 10</div>
<div class="lr"><div class="ld" style="background:#9be54a"></div>11 ~ 30</div>
<div class="lr"><div class="ld" style="background:#d29922"></div>31 ~ 80</div>
<div class="lr"><div class="ld" style="background:#f0883e"></div>81 ~ 200</div>
<div class="lr"><div class="ld" style="background:#da3633"></div>200+</div>
<div style="margin-top:6px;border-top:1px solid #30363d;padding-top:6px">
<div class="lr" style="color:#79c0ff">▬ Blue edges = callers (in)</div>
<div class="lr" style="color:#7ee787">▬ Green edges = callees (out)</div>
<div class="lr" style="color:#f0883e">● Orange = selected root</div>
</div>
</div>
<div id="btns">
<button onclick="zoomBy(1.3)">➕</button>
<button onclick="zoomBy(0.7)">➖</button>
<button onclick="resetView()">🔄 Reset</button>
<button id="be" onclick="togEdge()">📐 Hide Edges</button>
<button onclick="togLabel()">🏷 Labels</button>
<button id="btn-anim" onclick="togAnim()">▶ Animate</button>