OllyDbg这个老牌的32位调试器,到今天依然是逆向我用过最顺手、最实在的工具之一。这篇文章要聊的是一个经典到不能再经典的场景:一个本地程序,启动后弹出了登录窗口,要求输入用户名和密码,验证通过才能进入主界面。我们要做的,就是通过动态调试,定位它验证逻辑的关键代码,理解判断分支的来龙去脉,然后恰到好处地修改一处跳转或一个字节,让程序直接认可我们输入的任意内容。整个过程不涉及爆破算法、不碰加密协议,纯粹围绕“验证流程”本身做文章。
这种技术能解决什么问题?比如你写了一个小工具,忘了后台管理员密码;或者你是安全测试人员,在授权范围内评估一款软件的登录机制是否会被轻易绕过;再比如CTF比赛里大量逆向题目就是这种“输入flag然后判断是否正确”的逻辑。理解OllyDbg下的跟踪思路,不仅是脚本小子的快乐,更是入门Windows逆向的基本功。这篇实战案例会从定位验证代码开始,一步步带你操作到最终保存补丁,适合刚刚上手动态调试、对寄存器跳转还不太敏感的读者,也欢迎老手在后面常见问题部分查漏补缺。
1. 案例背景与逆向分析准备
1.1 登录验证流程的常见实现方式
本地程序的登录验证,跟你在网页后端见到的多少有些不同。后端通常会走数据库查询、密码哈希比对、Token校验等;而单机程序里,逻辑往往更“直白”。最常见的几种形态:
- 硬编码字符串比对:用户名、密码明文或者简单编码后直接写在程序文件里,输入内容通过某种比较函数和硬编码值比对。
- 算法生成比对:程序内部有一个注册码或动态口令算法,根据用户名计算出一个结果,跟输入的密码比较。
- 文件存储比对:密码写在配置文件、注册表或者一个加密后的数据文件里,程序启动时读出来比对。
- 服务端校验:虽然看起来是本地登录,但实际发包到服务器验证,这种就不能只靠本地调试解决了。
本案例属于第一种,也是最适合拿来学习动态调试流程的类型:一段固定的字符串(比如“admin”和“123456”)藏在程序的代码段或数据段里,登录按钮点击后,程序把输入框内容和这段字符串做比较,相等就走成功分支,不等就走失败分支并弹出错误提示。我们的核心思路,就是找到这个比较发生的位置,看清它后面的跳转条件,然后利用调试器修改执行流。
1.2 搭建本地调试环境
有几点前置准备,我强烈建议你不要偷懒:
- 操作系统使用Windows 7或Windows 10虚拟机。OllyDbg对现代Windows的兼容性没有想象中那么好,尤其是带UI缩放和High DPI的情况下,调试界面会发虚。虚拟机里跑一个纯净32位系统,既能避免环境干扰,又方便随时快照恢复。
- 调试器选择OllyDbg 1.10英文版或2.01版本。如果你用中文插件,界面会亲切一点,但很多资料和指令都是英文的,我个人建议先用英文版,把命令和窗口名称记熟了再换汉化版也不迟。
- 准备一个待分析的程序。不要直接拿商业软件练手,一定要选你拥有合法使用权的程序,比如自己写的Demo、开源项目编译出来的工具、或者CTF平台提供的靶机程序。这里我用的是一个叫“某设备管理工具Demo.exe”的虚构程序,弹窗提示“Invalid username or password”,文件名和字符串都是虚拟的,但操作过程完全可以复现到类似程序上。
- 额外准备:十六进制编辑器和坚果云/网盘等用于保存附件。实际上保存补丁用OllyDbg自带功能就够了,但备份原始文件这个习惯必须有。
1.3 首次运行与信息收集
把Demo.exe复制到虚拟机中,先直接双击运行。正常情况下程序会显示一个登录窗口,包含两个输入框(用户名、密码)和一个登录按钮。我先输入一个错误的账号密码组合,比如“test”和“123”,点击登录,程序弹出一个MessageBox,内容写的是“Invalid username or password”。
这一步看似简单,实际上暴露了非常重要的信息:程序会给我们一个明确的人机交互出口,而这个出口必然离验证逻辑很近。我们接下来要做的,就是在调试器中抓到这个提示字符串背后的代码位置。OllyDbg加载程序时,直接拖拽Demo.exe到调试器界面即可,或者用File -> Open。调试器启动后,程序会暂停在系统断点处,按F9继续运行,就会看到同样的登录窗口。
2. 核心细节解析:定位验证代码的套路
登录窗口已经显示出来了,接下来怎么找到验证代码?有经验的逆向工程师会有一套固定的排查顺序,很少会像无头苍蝇一样乱点。
2.1 字符串交叉引用定位
最常用、最省事的办法,就是直接搜索错误提示字符串里的特征片段。在OllyDbg中,切换到CPU窗口,将当前模块标记为Demo.exe后,右键 -> Search for -> All referenced text strings。注意,这个功能只搜索数据段中可引用的字符串,如果你的程序是加壳的,这里可能什么都搜不到,那是后话。
在打开的字符串列表窗口里,按下Ctrl+F,输入“Invalid”或者“password”之类的关键词,一般能直接找到。双击列出结果中的对应行,CPU窗口会自动跳到保存该字符串的内存地址。此时你看到的地址通常位于程序的只读数据区(.rdata),还不是真正的逻辑代码。我们还需要知道是哪里引用了这个字符串。
在数据区该字符串地址上右键,选择“Find references”或按Ctrl+R,OllyDbg会列出所有引用这个字符串的汇编指令。通常结果不多,一两条,比如某个push指令,把字符串地址压入栈作为MessageBoxA的参数。双击这条引用,跳到代码区,你便已经站在登录验证逻辑的门口了。
2.2 按钮事件断点与调用栈回溯
字符串交叉引用适用于提示信息比较明显的程序。如果程序没有任何提示,或者验证失败时干脆不弹窗,只是界面闪烁一下,那就要换思路了。
对于常规的Win32程序,登录按钮点击后,Windows会发送WM_COMMAND消息到窗口过程,其中wParam的低16位是按钮的控件ID。我们可以在OllyDbg中下消息断点,比如bp WM_COMMAND,但这会命中所有按钮的消息,需要进一步判断来源窗口和控件ID。更实际的做法是,在窗口过程的回调函数开头下一个条件断点,条件是uMsg==0x0111(WM_COMMAND),然后观察控件ID。
如果你用的工具有UI界面,不好直接看窗口过程,可以先对MessageBoxA(错误提示弹窗)下断点。点击登录按钮触发弹窗时,程序会断在调用MessageBoxA的位置,此时调用栈窗口(Alt+K)会记录完整的函数调用链。沿着栈帧往回逐一查看,你会找到一层层的call指令,最接近底层的那个非系统函数往往就是处理登录的逻辑。这个方法比追踪窗口消息更直接,因为弹窗是验证失败必经的一环。
2.3 内存中密码比较的蛛丝马迹
第三种思路是从API调用入手。很多简单的登录程序不会自己写一个逐字节比较的循环,而是直接调用系统比较函数,比如C运行时库的strcmp、Windows API的lstrcmpA、或者剪贴板比较函数。OllyDbg可以对这几个函数下断点:
bp strcmp bp lstrcmpA bp lstrcmpW bp memcmp下好断点后,每次点击登录,程序如果有调用这些函数,就会在函数入口处停下。此时查看寄存器窗口,通常EAX和EDX或ECX指向的就是两个待比较字符串的内存地址。在数据窗口跟随这些地址,不仅可以看到你输入的用户名密码,还能顺藤摸瓜看到真正的硬编码密码。对于很多入门级程序,这一招比什么字符串交叉引用都简单粗暴,甚至在函数入口改寄存器值就能直接让比较成功。
3. 实操过程:从动态调试到绕过验证
3.1 在字符串引用处下断点
我们回到最容易复现的字符串定位路线。第一步,对保存“Invalid username or password”字符串引用处的代码行设置断点:选中引用处的push demo.Invalidusername...行,按F2。设置好后,该行地址会变为全行红色高亮。
现在回到登录窗口,重新输入一个错误的用户名和密码,比如“user”和“wrongpass”,点击登录。OllyDbg会在断点处停下来。这时我们看到堆栈窗口里已经出现了后续MessageBoxA的参数,但这个位置晚于验证逻辑的真正分支。
接下来按F8(Step Over,单步步入)逐步执行。当你看到一条call demo.xxx调用某个比较函数之后,紧接着出现test eax, eax或cmp eax, 0x0这样的指令时,恭喜,你找到了判断的地方。
3.2 分析关键跳转指令
比较函数反汇编之后,代码大致长这样(地址是虚构的,但结构完全一致):
00401250 mov edx, [local.1] ; 输入的用户名指针 00401253 mov eax, [local.2] ; 输入密码指针 00401256 call demo.<sub_4011F0> ; 内部比较函数,返回BOOL 0040125B test eax, eax 0040125D jnz short loc_401264 ; 如果eax不为0,跳到失败分支 0040125F push 0x1 00401261 call demo.MessageBox_Success 00401264 loc_401264: 00401264 push 0x0 00401266 call demo.MessageBox_Error逐行解释:第1250和1253行把两个参数放入EDX和EAX,后面那个call是内部函数,它内部可能调用了strcmp,也可能自己做了处理。比较函数返回后,几乎所有标准实现都会把返回值放入EAX,随后用test eax, eax来检查EAX是否为0。test指令执行的是“按位与”操作,结果不保存,只影响标志位。如果EAX为0,ZF(零标志位)置1;如果非0,ZF清0。
接下来这条jnz short loc_401264是关键。JNZ的含义是“如果ZF=0则跳转”,也就是“如果比较结果不是0则跳转”。在循环比较类的函数中,返回0通常表示字符串完全匹配。所以你看到这个逻辑就是:字符串不相同,返回非0,ZF=0,于是跳过成功提示,直接走到弹错误框。字符串相同,返回0,ZF=1,则不会跳到错误分支,继续执行下面的成功提示并弹窗。
理解这个判断是绕过的原理核心。我们要让程序在输入任意内容时都表现成“字符串相同”的状态。
3.3 修改跳转指令实现绕过
这时候有三条路可选,各有利弊,我分别说说。
第一,修改跳转条件。双击jnz short loc_401264,将其改成jz short loc_401264。点击后OllyDbg会弹出一个修改对话框,把JNZ改成JZ,再点击确定。这样,只有在比较结果为0(相同)时才跳到错误分支,而我们的输入永远跟硬编码密码不一样,结果反而不会跳到错误分支,会直接走成功提示。但这种方式只改变了“相同”和“不同”的对应关系,如果程序有多个比较分支,后续逻辑可能混乱。在这个单分支的例子里,由于成功分支和错误分支是互斥的,改JNZ为JZ是可行的。
第二,无条件跳转。把jnz short loc_401264改成jmp short loc_401260,直接跳到成功分支。这里要注意,成功分支是连续代码,不存在单独的标签,所以你要把跳转目标填成成功分支的下一条指令地址,即0040125F。如果改成jmp short loc_401264反而会跳到错误分支,那就白改了。
第三,把条件跳转NOP掉。选中该行,右键 -> Binary -> Fill with NOPs,将原指令的2个字节替换成90 90。由于成功分支在顺序流中位于错误分支之前,NOP掉跳转后,程序无论比较结果如何都会执行成功提示,然后再继续往下执行错误提示,结果会同时弹出两个对话框,虽然主逻辑也转变了,但体验不好。如果错误提示是调用ExitProcess之类的结束函数,那NOP掉跳转其实也有效,因为执行到错误分支就会退出,只是在退出前会弹一下成功提示。
综合考虑,本案例推荐用第一种或第二种,看你想演示修改指令还是修改操纵流。真实逆向中,最常用的是“改JNZ为JMP直接跳转到成功分支”,因为这样的修改对逻辑链的破坏最小,也最直观。
3.4 保存补丁并生成可执行文件
修改成功后,程序会以调试态的“伪代码”形式运行。要让修改永久生效,我们需要把它写回文件。
在OllyDbg中,右键修改过的代码,选择“Copy to executable” -> “All modifications”,弹出的窗口会列出所有改动字节的地址和原始值、新值。确认没有多余改动后,点击“Save file”,输入一个新的文件名,比如“Demo_patched.exe”,关闭弹窗。然后打开Windows资源管理器确认新文件已经生成。
此时用原始Demo.exe备份一下,再双击运行Demo_patched.exe。输入任意用户名密码,比如“whatever”和“0000”,点击登录,程序直接弹出成功提示。整个绕过验证流程就完成了。
3.5 验证绕过结果
这里有一个容易被忽略的细节:修改之后的补丁程序,能不能在我们“不调试”的情况下正常启动?OllyDbg保存补丁时,只是修改了磁盘文件中的几个字节,并未修改PE结构和校验值。所以绝大多数无壳程序都能直接运行。但如果程序有完整性校验,比如在启动时计算自身CRC,那么补丁程序会被识别为被篡改而拒绝运行。这种情况我们留到第四节讲。
同时建议你在调试前先记录原始文件的MD5或SHA1值,保存补丁后对比。这样一方面确认改动的最小化,另一方面也能帮你理解自校验程序的校验目标是什么。
4. 常见问题与排查技巧实录
4.1 断点断不下来怎么办
最常见的情况是:你已经在字符串引用处下了断点,但点击登录按钮后程序根本没停。可能的原因有三个。
第一,字符串不在程序模块里,而在系统DLL或某个动态加载的模块中。在OllyDbg中,下断点时如果提示“Cannot set breakpoint”或者断点列表里的地址前缀是DLL名称,说明引用并没有真正关联到主程序的代码。你需要检查是否定位到了正确的模块,按Alt+E打开模块窗口,确保你的断点地址落在Demo.exe的代码段中。
第二,程序使用了多个线程或消息循环,登录验证在另一个线程里执行。OllyDbg默认只会中断当前线程,其他线程的断点不会引起主线程暂停。可以在断点属性中勾选“Any thread”,或者在断点列表里右键选择“Break on all threads”。
第三,你设置的断点位置实际不会被执行。有些程序在比较之前会把字符串复制到动态缓冲区,真正的错误提示可能来自另一个字符串,而不是我们搜索到的那个。这时候建议换strcmp等API断点,或者同时搜索“Invalid”“wrong”“error”多个关键词,扩大定位范围。
4.2 反调试与自校验的应对
如果你的目标程序是商业授权软件,多半会遇到反调试。简单场景是程序在启动时调用IsDebuggerPresent,检测到调试器存在就直接退出。OllyDbg本身对这类API没有自动隐藏能力,需要配合插件。
第一代隐藏方法:使用OllyDbg插件HideOD或StrongOD,Hook掉常见的调试器检测API,如IsDebuggerPresent、NtQueryInformationProcess、OutputDebugString等。装上后默认开启隐藏,大部分通用检测都能绕过去。
第二代方法:手动修改执行流。如果你发现程序在某个地址调用IsDebuggerPresent后紧跟着一个test eax,eax和jne跳转到exit,那就直接把这个跳转NOP掉。这种方式更稳,因为不依赖插件对API的Hook是否完整。
自校验是另一个头疼问题。常见做法是程序启动后读取自身文件,计算CRC32或MD5,与嵌入在程序中的值比较。如果你修改了文件并保存,这个校验值就对不上了,程序会崩溃或弹窗提示文件损坏。
典型的处理思路是分两步走:先正常调试到自校验函数,在主校验函数返回后修改返回值(比如强制为0),然后再去修改验证逻辑;或者在定位到自校验的全部代码后,找到校验分支,将其跳转改成无条件的“正确”分支。对于单个校验点,我们可以直接修改;对于多层循环校验的,更需要的是从根上定位到校验函数的入口,让程序根本不执行校验逻辑。比如程序在main函数最开始调用一个CheckSelf(),如果返回值非0就退出,那我们就在CheckSelf()的返回处将eax清零。
4.3 字符串找不到的替代定位方法
有的程序会对字符串进行加密或动态生成,直接搜索“Invalid username or password”会一无所获。遇到这种情况,不要再纠结于字符串,改用“内存访问断点”或“硬件断点”来监控输入内容。
第一种方式是在登录窗口下断点,假设我们找到了输入框所属的EDIT控件,可以在堆栈窗口中看到Edit控件的窗口句柄。OllyDbg可以在该句柄指向的内存的缓冲区上设置硬件访问断点,当程序读取输入文本框内容时就执行中断。不过这对内存读取指令的判断比较繁琐。
更实用的是使用OllyDbg的“Run Trace”功能。在断下后按Ctrl+T调出Trace窗口,设置条件为“发现跳转指令记录”或“访问特定API”。我们可以让程序在运行时跟踪全程,然后过滤出所有访问特定内存地址的指令。对于字符串动态加密的情况,可以先用内存断点捕捉到解密完成后的明文数据,再一次性定位到引用它的代码。
这些都是相对进阶的技巧,初学时只需要熟练掌握字符串和API断点就够了。但了解这些替代方案,有助于你遇到顽固加壳或反调试程序时能够思考更深一点。
5. 逆向分析的边界与合法使用建议
绕过一个本地登录验证这个动作,本身没有善恶属性。你在自己的程序上调试,发现自己写的登录逻辑太容易被跳过,然后加上更严格的校验,这是防御;你在CTF比赛中解逆向题,这是竞技;你在授权范围内对某软件做安全评估,这是职业。但如果你拿这套技巧去掉别人程序的授权限制,或者绕过收费软件的许可验证,那就是另一回事了。每次分析之前,要问自己三个问题:我拥有这个程序的使用权吗?我是否有书面授权来进行逆向测试?分析结果是否只用于合法学习和研究?
OllyDbg这类工具在国内外的安全圈里属于通用基础技能,你还会看到很多教程讲解脱壳、协议分析、漏洞挖掘,这些都是安全从业者的营养品。但同时也要理解,所谓“绕过登录验证流程”的技术核心是理解程序执行流,学会看清分支跳转,本质上是让你对计算机程序的运行方式有更深入的认识。把这种认识用到保护自己程序、修复漏洞的方向上,才是更有价值的事。
我个人在实际操作中有一个很深的体会:修改跳转只是最表面的结果,真正值钱的是你在调试过程中建立起来的“代码执行流”思维。你会慢慢看到程序不是一个个孤立的函数,而是一张由跳转和调用组成的大网,登录验证不过是这张网上稍微需要点判断力的小节点。当你拿到一个陌生程序,能迅速画出它的分支地图时,你已经具备了一个合格逆向分析人员的基本素养。最后再分享一个小技巧:调试时每进行一次关键修改,都先在F9跑一遍确认修改有效,再继续下一处。不要一次改多个跳转,否则哪个生效、哪处引发的副作用你会完全摸不着头脑。耐心和增量排查,才是逆向调试这门手艺里最被低估的武器。