资讯动态

用Python重写findcrypt:IDA Pro与YARA深度整合定位加密常量

发布时间:2026/10/19 12:51:08 来源:尧图企业网站定制
这年头做逆向工程尤其是常年在固件和恶意样本里摸爬滚打的人应该都体会过那种“明明感觉程序里藏着AES或者RSA却怎么都定位不到”的焦躁。人工翻滚动窗口找S盒、找魔数、找置换表眼睛能看出花来。后来社区里开始流传一个叫findcrypt的小插件它把加密常量的搜索自动化了很多分析任务从此变成“插件一跑算法全显形”。这篇文章不是翻译原版插件的说明文档而是要完整记录我自己用Python重写一个findcrypt风格插件并把IDA Pro的Python API和YARA引擎深度整合在一起的整个过程。包括怎么读取已经加载进IDB的二进制内容、怎么把匹配到的文件偏移精确换算成虚拟地址、怎么做名字和注释的批量标记、怎么保证插件在几个大版本IDA上都能跑稳以及我在真实样本上踩过的坑。如果你正准备自己写IDA插件或者想彻底搞懂findcrypt背后的原理这篇文章可以直接当参考手册用。1. 整体方案设计与工作流程1.1 先搞清楚findcrypt到底帮我们干了什么很多加密算法在二进制里会留下非常顽固的“指纹”最常见的就是常量表。AES有256字节的S盒和逆S盒DES有初始置换表和逆置换表MD5、SHA1、SHA256有一串初始哈希值RC4初始化时会构造一个从0到255依次排列的S表TEA家族有固定的delta常量。这些常量在算法实现里几乎不会变只要它们以明文形式出现在代码或数据段里就等于给分析者递了一张写着“我在这里”的名片。人工去搜这些名片非常痛苦。早年我做样本分析遇到一个疑似用了加密算法的程序只能靠XSearch或者自己写IDC脚本对着十六进制窗口一段一段找。先记住AES S盒开头那几个字节再目测附近有没有类似结构效率低不说还容易漏。就算找到了算法名和地址之间的对应关系也全靠脑子记后续写报告还得重新整理一遍。findcrypt这类插件解决的就是这个问题把已知加密常量做成规则库让工具自动在二进制里扫描命中之后直接把算法名、常量位置和引用上下文标出来。我们自己实现一个核心本质就是把“二进制文件内容交给匹配引擎再把命中位置映射回IDA视图”这条链路跑通。搞清楚这个本质后面每一步代码都是围绕它展开的。1.2 为什么选YARA而不是用IDA自带的匹配API有人会问IDAPython里不是有find_binary、find_bytes这类函数吗直接遍历找模式不就行了何必再引一个YARA进来理论上可以但实际用起来会很难受。find_binary这类API适合少量、简单的字节模式查找规则一多代码立刻变成一团乱麻。而且它是在IDA的线性地址空间里逐位置匹配对动辄几十MB的固件来说性能扛不住。我曾经用IDC脚本搜一个四十几字节的常量模式跑到一半直接卡死最后只能杀掉IDB进程。YARA的核心优势有三点规则是纯文本文件可维护性极强。加一条规则就是加几行文本不用改插件代码。规则语法表达能力远超简单模式匹配支持十六进制通配符??、可变跳转[4-6]、字符串变量、逻辑条件、元数据甚至可以在规则里写多模式组合。匹配引擎本身是C语言实现的底层做了很多优化处理大块二进制数据的速度非常快。同样几十MB的数据YARA可能几秒钟就跑完了。还有一个容易被忽略的好处同一套YARA规则文件完全可以在命令行用yara64直接扫磁盘上的样本并不依赖IDA。这就意味着规则库可以独立维护在样本筛选阶段先用命令行工具跑一遍疑似有加密算法的样本再丢进IDA精确定位。这种“规则资产复用”的价值用IDC硬写是体会不到的。2. IDA Python API关键接口选型与踩坑实录2.1 读取已加载二进制内容get_bytes系列的正确姿势插件要做匹配第一步就是把二进制内容拿进Python内存。这里最关键的一个思路是不要直接去读磁盘上的原始文件而应该读取IDA已经加载进IDB的字节。原因很简单分析过程中你可能对数据打过补丁、改过字节甚至当前磁盘文件已经和IDA加载的内容完全不同了。直接读磁盘会造成“分析现场”和“数据内容”不一致匹配出来的结果没有意义。必须以IDA内部视图为准扫出来的命中才是你在反汇编窗口里看到的东西。IDA的Python API提供了一系列读取字节的函数。早年大家常用idc.get_bytes(ea, size)这个函数在IDA 7.x早期版本还能用但有Deprecation警告到了8.x部分版本已经直接被移除。现在最稳的是ida_bytes.get_bytes(ea, size)参数完全一样返回一个bytes对象读取失败返回None。这里有个新手一定会踩的坑以为可以用ida_bytes.get_bytes(0, max_ea)一次性把整个IDB读出来。实际上IDB的地址空间并不是连续的中间可能有大段空洞、未加载区域、以及各种非文件映射段。一次性从0读到max_ea要么返回None要么读出来的长度和你想要的根本不是一回事还会因为遇到无效区域产生不可预知的行为。正确做法是遍历段按段读取再拼接。代码骨架如下import idaapi import ida_bytes def collect_segment_bytes(): chunks [] segs [] base 0 for i in range(idaapi.get_segm_qty()): seg idaapi.getnseg(i) if seg is None or seg.size() 0: continue if seg.fileoff idaapi.BADADDR: # 没有文件映射的段如BSS continue data ida_bytes.get_bytes(seg.start_ea, seg.size()) if data is None: continue chunks.append(data) segs.append((base, seg.start_ea, len(data))) base len(data) if not chunks: return b, [] return b.join(chunks), segs这里我额外维护了一个segs列表每一项记录三个信息这个段在拼接后缓冲区中的起始偏移base、段的虚拟地址起点start_ea、段内容长度size。为什么一定要记这个后面偏移映射时要靠它先挖个坑在这里。过滤seg.fileoff idaapi.BADADDR也很重要。BSS段或者某些没有对应文件内容的段fileoff是无效值强行去读得到的字节是无效的匹配出来也是噪声。2.2 文件偏移到虚拟地址的映射匹配命中的定位关键YARA匹配返回的偏移是相对于你传入的data字节串的下标。如果你把多个段拼接成一个连续的data那这个下标就和一个文件偏移没有任何直接关系必须通过之前记录的segs列表换算回虚拟地址。换算公式很简单def mapped_offset_to_ea(offset, segs): for base, start_ea, size in segs: if base offset base size: return start_ea (offset - base) return idaapi.BADADDR逻辑就是找到命中偏移落在哪个段的拼接区间里然后减去该段在拼接缓冲区中的起点base得到段内相对偏移再加上该段的虚拟地址起始值start_ea就是真正的EA。这个思路和网上有些版本直接使用seg.fileoff来计算不一样。如果按段的fileoff来映射那必须保证拼接缓冲区中每一个字节的位置和原始文件的偏移完全一致。这要求你在拼接时把段与段之间的空洞全部用填充字节补齐否则下标对不上。补齐空洞不仅浪费内存还可能制造出根本不存在的“伪数据”让YARA产生误报。与其绕那个弯子不如直接在拼接时记录base偏移简单、准确、不产生额外开销。这里有一个容易被忽视的边界问题如果IDA中存在重叠段也就是同一个文件区间被映射到了多个虚拟地址那么遍历段时只会命中第一个匹配条件。这在绝大多数分析场景下不是问题因为常量表很少会被映射到两个不同地址还都能用上。但如果你遇到了特殊loader造出来的重叠映射心里要清楚这个限制。2.3 标记与可视化set_name、set_cmt、set_color位置找到了下一步就是让命中结果在IDA界面里真正“可见”。一个合格的findcrypt插件至少要干三件事给地址命名、加注释、上颜色。命名用idaapi.set_name(ea, name, flags)。第一个坑是YARA规则名并不一定是合法的IDA名字。规则名可能包含数字开头、横线、空格之类直接set_name会失败。我习惯先做一个清洗import re def sanitize_name(rule): cleaned re.sub(r[^0-9a-zA-Z_], _, rule) if cleaned and cleaned[0].isdigit(): cleaned _ cleaned return cleanedSN_NOCHECK | SN_NOWARN这两个flag一定要带上。SN_NOWARN避免命名冲突时弹出烦人的对话框SN_NOCHECK表示不做名称合法性检查反正我们已经清洗过了让它直接改。注意如果目标地址已经被命名为别的非默认名字set_name会失败返回False。这个问题在重复运行插件时尤其明显后面常见问题部分会专门讲。注释用idc.set_cmt(ea, text, repeatable)。第三个参数传0表示普通注释传1表示可重复注释。我一般用普通注释就够了内容里带上规则名、命中的字符串标识符和原始偏移方便后续溯源。高亮用idc.set_color(ea, idc.CIC_ITEM, color)。颜色值是个整数我习惯用一个高亮绿色0x7CFC00扫完之后整个反汇编窗口里所有加密常量一眼就能扫出来。颜色可以按自己的喜好调但建议选用不容易和已有背景色混淆的亮色。还有一个超实用的细节向输出窗口打印命中结果时只要把地址格式化成0x%X这种标准十六进制形式ida_kernwin.msg打出来的内容在输出窗口里会自动变成可点击的链接点击就能跳转到反汇编视图。不要打印纯十进制或者不带0x前缀的十六进制字符串否则没法点击跳转。2.4 插件生命周期从PLUGIN_ENTRY到run一个IDA插件的骨架其实很固定。插件文件放到IDA的plugins目录下IDA启动时会自动扫描并加载。插件主体是一个继承idaapi.plugin_t的类需要实现init、run、term三个方法最后通过PLUGIN_ENTRY导出。import idaapi import ida_kernwin class FindCryptYaraPlugin(idaapi.plugin_t): flags idaapi.PLUGIN_FIX comment Scan crypto constants using YARA rules help Scan crypto constants using YARA rules wanted_name FindCrypt YARA wanted_hotkey def init(self): try: import yara except ImportError: ida_kernwin.warning(yara-python is not installed) return idaapi.PLUGIN_SKIP return idaapi.PLUGIN_OK def run(self, arg): scan() def term(self): pass def PLUGIN_ENTRY(): return FindCryptYaraPlugin()flags里的PLUGIN_FIX表示插件常驻IDA每次启动都初始化。init()返回值很关键返回PLUGIN_OK插件正常装载并出现在菜单里返回PLUGIN_SKIP则插件不可用通常是因为依赖缺失。run(arg)才是真正干活的地方arg是菜单点击时IDA传入的参数一般用不到。PLUGIN_ENTRY是必须的导出函数IDA通过它拿到插件对象。一个细节这里的import yara在init和run里都做一次是为了避免模块级导入导致插件加载时就直接抛异常。插件系统加载一个抛异常的模块会把整个插件列表搞乱所以依赖检查坚决放在init里。3. 插件核心实现详解3.1 规则文件的组织与加载规则文件我习惯放在IDA用户目录下路径通过idaapi.get_user_idadir()获取然后用os.path.join拼出完整路径。这样插件升级时不会覆盖规则文件而且用户可以随时编辑规则不用重新编译插件。idaapi.get_user_idadir()在不同平台分别指向IDA的用户配置目录比如Windows下通常是AppData/Roaming/Hex-Rays/IDA Pro具体不用记代码里动态获取就行。规则文件的内容长得像这样rule AES_SBOX { strings: $a1 {63 7C 77 7B F2 6B 6F C5 30 01 67 2B FE D7 AB 76} $a2 {CA FA BE ... } condition: $a1 and $a2 } rule RC4_INITIAL_SBOX { strings: $a1 {00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F} condition: $a1 }加载和编译用yara.compile(filepathrule_path)这个调用会读取文件并编译所有规则。如果规则文件里有语法错误会抛yara.SyntaxError异常信息里包含行号直接捕获并弹窗提示比让插件静默崩掉友好得多。为什么强调规则文件独立而不是把规则硬编码进Python代码因为YARA规则本身是一个活跃维护的资产。你可以在命令行用同一个规则文件检测样本也可以从社区的开源规则库里挑选、合并、扩充规则。规则和代码分离插件就变成了一个纯执行引擎规则更新完全不依赖代码升级这是工程上的正解。3.2 扫描主流程与完整代码整个扫描流程可以拆成五步检查规则文件、编译规则、读取段字节、执行匹配、处理并标记命中。把前面所有内容整合起来就是一个可以直接抄作业的完整实现import os import re import idaapi import ida_bytes import idc import ida_kernwin import yara def collect_segment_bytes(): chunks [] segs [] base 0 for i in range(idaapi.get_segm_qty()): seg idaapi.getnseg(i) if seg is None or seg.size() 0: continue if seg.fileoff idaapi.BADADDR: continue data ida_bytes.get_bytes(seg.start_ea, seg.size()) if data is None: continue chunks.append(data) segs.append((base, seg.start_ea, len(data))) base len(data) if not chunks: return b, [] return b.join(chunks), segs def mapped_offset_to_ea(offset, segs): for base, start_ea, size in segs: if base offset base size: return start_ea (offset - base) return idaapi.BADADDR def sanitize_name(rule): cleaned re.sub(r[^0-9a-zA-Z_], _, rule) if cleaned and cleaned[0].isdigit(): cleaned _ cleaned return cleaned def scan(): rule_path os.path.join(idaapi.get_user_idadir(), findcrypt.yara) if not os.path.isfile(rule_path): ida_kernwin.warning(Rule file not found: rule_path) return try: rules yara.compile(filepathrule_path) except yara.SyntaxError as e: ida_kernwin.warning(YARA syntax error: %s % e) return data, segs collect_segment_bytes() if not data: ida_kernwin.warning(No segment data is available) return ida_kernwin.msg([FindCrypt] Read %d bytes from %d segments\n % (len(data), len(segs))) try: matches rules.match(datadata, timeout120) except yara.TimeoutError: ida_kernwin.warning(YARA scan timeout) return total 0 first_ea idaapi.BADADDR for m in matches: for off, ident, plain in m.strings: ea mapped_offset_to_ea(off, segs) if ea idaapi.BADADDR: continue if first_ea idaapi.BADADDR: first_ea ea name sanitize_name(m.rule) idaapi.set_name(ea, name, idaapi.SN_NOWARN | idaapi.SN_NOCHECK) idc.set_cmt(ea, [FindCrypt] %s (match %s) % (m.rule, ident), 0) idc.set_color(ea, idc.CIC_ITEM, 0x7CFC00) ida_kernwin.msg([FindCrypt] 0x%X : %s\n % (ea, m.rule)) total 1 if first_ea ! idaapi.BADADDR: ida_kernwin.jumpto(first_ea) ida_kernwin.msg([FindCrypt] Done. Total %d matches.\n % total)这段代码里有一个容易被忽略但值得强调的点m.strings返回的是一个三元组列表(offset, identifier, matched_bytes)。offset是命中的字节串在data中的起始下标也就是我们要拿去映射的偏移。identifier是规则里的字符串标识符$a1、$a2之类方便在注释里标注是哪一条规则中的哪个字符串命中。matched_bytes是实际匹配到的字节内容虽然这个版本没用上但在调试规则时可以打印出来核对。timeout120参数是YARA Python绑定提供的扫描超时保护。规则库越来越大之后某些复杂规则在超大文件上可能跑很久设置超时可以避免插件把IDA整个拖死。老版本的yara-python可能没有这个参数如果报错直接去掉即可。3.3 处理大文件与性能优化整个插件最脆弱的环节就是内存占用。前面b.join(chunks)这行代码把多个段的字节串复制成一个大串对几十MB的固件还好对上GB的IDB就有点吃力了。复制过程会产生双倍内存峰值再加上YARA匹配时内部还会拷贝一份数据内存很容易爆。针对这个问题我有两个优化方案。第一个是阈值切换。文件小于一个阈值比如10MB时用全量拼接保证速度和规则跨段匹配能力文件大了改成分段匹配每次只把单个段的字节传给rules.match。分段匹配的代码改动很小def scan_stream(): rules yara.compile(filepathrule_path) total 0 for i in range(idaapi.get_segm_qty()): seg idaapi.getnseg(i) if seg is None or seg.size() 0: continue if seg.fileoff idaapi.BADADDR: continue data ida_bytes.get_bytes(seg.start_ea, seg.size()) if data is None: continue matches rules.match(datadata, timeout120) for m in matches: for off, ident, plain in m.strings: ea seg.start_ea off # 后面的标记逻辑相同分段匹配最大的好处是映射逻辑变得极其简单命中偏移直接加段起始地址就是EA。代价是YARA引擎会被多次调用每次都有初始化开销以及对跨段边界的常量模式会漏掉。好在加密常量表基本不可能跨越两个不同的段这个代价在实际分析中可以接受。第二个优化是加扫描进度提示。大文件扫描耗时较长什么都不显示会让用户以为IDA卡死了。用ida_kernwin.show_wait_box(Scanning...)在扫描前弹出等待框扫描完成后ida_kernwin.hide_wait_box()关闭。这样至少用户知道插件还在干活不会去给IDA发SIGKILL。3.4 扩展从常量命中到函数级线索只标记常量本身对后续分析还是不够友好。我现在使用的版本在标记常量之外还会尝试把命中的上下文联动到函数级别。具体做法是拿到命中EA后调用ida_funcs.get_func(ea)检查这个地址是否落在某个函数内。如果存在且函数名还是默认的sub_XXX形态就把函数也重命名并加上注释。例如AES的S盒被.rodata段引用那个引用它的函数很可能就是AES的加密或解密实现给它命个名后续F5反编译时识别起来快得多。import ida_funcs func ida_funcs.get_func(ea) if func is not None: fname ida_funcs.get_func_name(func.start_ea) if fname.startswith(sub_): new_func_name crypto_%s % sanitize_name(m.rule) idaapi.set_name(func.start_ea, new_func_name, idaapi.SN_NOWARN | idaapi.SN_NOCHECK) idc.set_func_cmt(func.start_ea, [FindCrypt] possibly uses %s % m.rule, 0)这段逻辑要谨慎只能在函数名还是默认sub_形态时改。如果名字已经被分析师手动改过千万不要覆盖那可能是别人花心思整理出来的结果。更进一步命中常量表之后还可以尝试创建数组类型。比如AES S盒是一个256字节的常量表可以给命中地址定义一个uint8_t[256]的数组类型让数据段在Hex-Rays视图里展示为数组而不是一坨无意义字节。这个需要用到ida_typeinf涉及类型创建和赋值偏进阶但对后续分析确实有价值。4. 常见问题与排查技巧实录4.1 yara-python装不上或导入失败症状插件菜单里干脆找不到FindCrypt YARA或者一加载就弹“yara-python is not installed”警告。原因基本就两类IDA自带的Python环境里没有装YARA绑定或者装错了架构。IDA是64位时就要求yara-python也必须是64位版本装了32位包照样导入失败。排查步骤很固定。先在IDA的Python命令行里执行import sys print(sys.version)确认当前解释器是哪个版本、多少位的。然后退出IDA用同一个Python环境的pip安装对应包pip install yara-python如果IDAPython用的是Python 2老版本IDA 7早期才有需要找兼容Python 2的旧版yara-python。现在主流IDA都是Python 3正常pip装最新版就好。装完不要直接重启IDA完事先在IDAPython命令行里执行import yara能成功导入再重启IDA。这个验证流程可以帮你把“插件问题”和“环境问题”快速分开。4.2 扫描结果全是空白一个都匹配不上这是最让人困惑的情况因为代码看起来没错、规则也加载了但跑完一个命中都没有。我排查这个问题通常分三步。第一步验证规则文件本身。先用命令行工具扫一下样本yara64 findcrypt.yara sample.bin命令行如果能命中说明规则文件没问题命令行也扫不出来那就是规则和样本确实对不上问题不在插件。很多样本在上传到分析环境之前被加壳了UPX或者各类混淆壳会把加密常量压缩或加密常量根本没暴露在文件里自然扫不出来。这种情况下应该先脱壳再用IDA插件扫。第二步检查data到底读了多少字节。插件输出里我特意加了一行Read %d bytes from %d segments如果读出来只有几KB说明段遍历有问题比如过滤条件把包含内容的段给跳过了。特别是seg.fileoff idaapi.BADADDR这个条件在一个异常解析的固件里可能大量段都落在过滤条件之外导致实际读到的字节数远小于文件大小。第三步检查规则文件路径。插件当前使用的是用户目录下的findcrypt.yara如果你把规则放在插件目录或者其他路径文件不存在时插件会弹警告。但如果你自己魔改过路径逻辑确认一下运行时用的路径是不是真的存在且可读。4.3 匹配结果偏移全部不对标记位置明显错误这个问题最开始折磨了我很久。表现是命中的算法名确实出现了但标记的地址看起来完全不在合理位置有时候甚至会标到代码段中间去。根源几乎都出在偏移映射上。我一开始也走过弯路直接把seg.fileoff当成拼接缓冲区的偏移来用结果前几个段推导出来的EA全偏了。原因前面说过拼接缓冲区中每个字节的下标和原始文件的偏移没有必然关系只有记录了每个段在缓冲区中的起始base值才能正确换算。不要嫌这个映射函数简单它才是整条链路里最容易写错的地方。排查方法也分享给你在插件里临时加一个自测函数对每一个读取过的段任意取一个段内已知地址验证mapped_offset_to_ea能不能把它映射回正确的EA。如果映射结果对不上直接打印出base、start_ea、size这三个值一眼就能看出是哪个段的记录出了问题。还有一个隐蔽场景IDA里存在SIB段映像信息时某些段的start_ea并不是你想象中加载进内存的地址而是原始文件映射区域的线性地址。遇到这种情况也没有什么通用银弹多看几个段的实际数值映射关系基本都能推算出来。4.4 某些规则性能差导致插件卡死规则库越攒越多之后性能问题会逐渐冒出来。有一次我把社区收集的几百条加密算法规则全部灌进一个文件跑一个30MB的固件直接卡了快十分钟。后来发现是某几条规则写了过于宽泛的模式比如包含大量??通配符但没有足够约束条件导致匹配引擎在最坏情况下要尝试大量位置。应对方式有三层给rules.match加timeout参数超时直接放弃至少插件还能退出。大文件走分段匹配模式把匹配引擎的输入数据规模降下来。维护规则文件时不能贪多按场景拆分成多个规则文件比如基础常量一个文件、特定协议一个文件。扫描时通过include或参数切换让插件只编译当前任务需要的规则。include base.yar include crypto_basic.yarYARA原生支持include语法在findcrypt.yara里用include组织子规则文件比在Python代码里合并多个规则对象方便得多。4.5 IDA版本兼容性IDA从7.0到现在大版本更新了好几次Python API一直在微调写插件不做兼容层换个版本就废。我踩过的主要变化点idc.get_bytes在IDA 7早期还能用后来逐渐被ida_bytes.get_bytes替代。idaapi.get_bytes在新版本中还在但官方更推荐ida_bytes。ida_kernwin模块在7.x之后成为GUI相关API的主要入口旧代码里依赖idaapi.msg、idaapi.jumpto的地方建议统一迁移到ida_kernwin。最简单的兼容策略是开头做一个导入适配try: from ida_bytes import get_bytes as ida_get_bytes except ImportError: from idc import get_bytes as ida_get_bytes这一小段代码让源码里只用ida_get_bytes这个统一入口底层走新API还是旧API交给适配层决定。插件换到新版本IDA时大部分情况下不用改业务代码。我在实际使用中最大的体会是逆向工具从来不是越炫越好而是要把“定位”这件事做扎实。YARA负责找IDA负责标两者一结合省下的是大量翻滚动窗口的时间。后来我又把规则库拆成base和custom两份custom放当前样本特有的常量特征配合批处理脚本一起跑效率又上了一个台阶。如果你也在做固件审计或者样本分析建议先按文中的思路把偏移映射和标记逻辑跑通再慢慢往规则库里加料。规则越准分析越省心插件也才真正从玩具变成生产力工具。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑