使用 IDA 和 windbg 调试 LNK1123 转换到 COFF 期间失败:文件无效或损坏(上)

使用 IDA 和 windbg 调试 LNK1123 转换到 COFF 期间失败:文件无效或损坏(上)

缘起

前一段时间在折腾拆分 rc 的问题,已经把遇到的问题整理成文了。感兴趣的小伙伴儿可以参考这里这里这里。本以为不会有问题了,后续流程就请其它同事帮忙处理了,没想到在拆分实际项目时遇到了一个非常奇怪的链接问题。

本文总结了使用 process monitor 监听进程创建,查看进程参数、使用 gflags 设置 Image File Excution Options、使用 IDA 静态分析相关函数的业务逻辑以及使用 windbg 进行动态调试的整个过程。我认为这是一个由不良的编程习惯与 crt 的限制共同导致的问题。快来一起看看吧。

初闻错误

前些日子,在家隔离办公的某日中午,收到同事发来的信息说 rc 拆分的编译问题已经解决了,但是遇到了链接错误,还发送了链接错误的截图,并且给出了一个解决方案。

cvt1101-lnk1123-in-real-project

尝试把 .rc 文件排除几十个就链接过去了。

听到这个问题的时候,我怀疑是不是哪里操作有问题。从错误提示看是 无法打开 xxx.res 进行读取,所以第一感觉是文件路径不对。于是赶紧跟同事聊了一下,同事觉得是 vs 的限制,可能这个限制数量是 512

但是我从没听过同一个工程中的 .rc 文件有数量限制,不管怎样,还是建个简单的工程验证下吧。

尝试重现

带着怀疑 + 好奇的心态,我快速新建了一个 MFC 对话框工程。然后在 vs 中不断复制默认对话框(大概复制了600 个,已经比同事所说的 512 上限要多了,如果有问题应该能重现了),然后使用工具把每个对话框拆分成独立的 .rc 文件并添加到工程文件中。保存好工程后,开始编译。等待一段时间后,果然报错了,错误截图如下:

cvt1101-lnk1123-in-test-project
cvt1101-lnk1123-in-test-project

从错误提示看,处理 dialog_testmultiplerccompile_dialog507.rc 文件的时候报错了。按照同事说的,删除若干个 .rc 文件,只保留 500 个,再次编译,没有报错。

看来,在同一个工程中包含太多 .rc 文件真可能有问题。难道真有限制?为什么会做这种限制呢?不管为什么要做限制,我需要找到一个解决方案。

开始深入调查前,先看看报错信息。

熟悉的错误

之前遇到过错误 LINK : fatal error LNK1123: 转换到 COFF 期间失败: 文件无效或损坏,是由于 link.execvtres.exe 的版本不一样导致的。这次报错不是这个原因。通过 process monitor 看,这两个程序的路径是一样的。

link-cvtres-same-path
link-cvtres-same-path

再看错误 error CVT1101: 无法打开“dialog_testmultiplerccompile_dialog507.res”进行读取。猜测是在读取这个文件的时候发生了错误,可以在 process monitor 中查看相关事件。

过滤相关事件

process monitor 中根据路径名进行过滤。如果路径以 dialog_testmultiplerccompile_dialog507.res 结尾则包含,如下图:

include-path-end-with-dialog507
include-path-end-with-dialog507

没想到一条记录都没有,一片空白。这是怎么回事?说实话,我有点不知所措,看来只能硬着头皮调试 + 用 IDA 逆向了。在调试之前,先用 IDA 看看有没有什么发现。

请出 IDA

使用 ida32 打开 cvtres.exeIDA 会提示是否查找符号(真是一个好消息),当然选择是。等待 IDA 分析完成后,在左侧的 Function window 中找到 _main,双击查看反汇编代码,直接在反汇编窗口按 F5,查看伪代码( IDAF5 真香!)。

大概浏览后,基本明白了 main() 函数的整体流程。首先,解析传入的参数,确定第一个文件在参数列表中的索引位置。然后,从此索引开始循环调用 ReadResFile() 读取每个文件,读取完所有的文件后统一调用 CvtRes() 函数进行转换。

下图是在 IDA 中对 main() 函数使用 F5 获得的伪代码的后半部分。

cvtres-main-function-logic
cvtres-main-function-logic

其中的 CvtRes() 函数应该是转换的主要函数,非常值得怀疑。迫不及待的启动 windbg 准备调试,但是 cvtres.exe 是被 link.exe 调用的,该如何调试呢?

搭建调试环境

如果 cvtres.exe 启动的时候,能够自动中断到调试器中,就可以方便的调试了。之前在 全局变量初始化顺序探究 中介绍过使用 gflags 进行设置的方法。

gflags-cvtres-setting
gflags-cvtres-setting

根据之前调试 cl.exe 的经验,如果长时间中断到调试器中,调用者会重新启动 cl.exe。猜想这里也会有类似的逻辑。为了避免这种问题,需要根据 link.exe 启动 cvtres.exe 的参数手动运行 cvtres.exe

可以通过 process monitor 很快找出 cvtres.exe 需要的参数。经过简单观察,发现传递给 cvtres.exe 的参数比较简单直接,而且根据 cvtres.exe /? 提供的帮助信息,可以很快确定各个参数的意义。

于是很快写出了一个批处理脚本,如下图:

cvtres-error-startup-bat
cvtres-error-startup-bat

没想到,双击脚本运行的时候,出现了如下错误:

windbg-cannot-start-cvtres-error
windbg-cannot-start-cvtres-error

提示找不到 cvtres.exe。看来需要使用完整路径。正确的脚本如下:

cvtres-startup-command
cvtres-startup-command

说明: 为了避免命令行参数过长,我特意简化了 .res 文件名,之前的名字太长了。而且经过测试,打开 510.res 的时候就能重现,没必要准备 600 多个 .res 进行测试,这里只准备了 511.res 文件进行测试。

猜错了

双击脚本启动 cvtres.exe,立刻就中断到了 windbg 中。

windbg 中执行 x cvtres!*main 即可找到入口函数,输入 bp cvtres!wmain 即可在 wmain() 函数入口处设置好断点。

同理,执行 x cvtres!*CvtRes 即可找到 cvtres!CvtRes() 函数,输入 bp cvtres!CvtRes 即可在 CvtRes() 函数入口处设置好断点。

设置好断点后,输入 g 让程序跑起来,可以发现 wmain() 函数内的断点命中了,但是 CvtRes() 函数内的断点并没有命中,进程直接退出了。

有些出乎意料,居然不是在 CvtRes() 函数里出的错。没(有)关(点)系(懵),继续挖掘有效信息。

继续努力

虽然进程退出了,但是依然可以通过 k 系列命令查看调用栈,在 windbg 中输入 kp,如下图:

cvtres-exit-call-stack
cvtres-exit-call-stack

上图中红色高亮部分就是关键调用栈。从上图还可以得到一个非常有用的信息 —— exit code 的值是 1。可以猜测,link.exe 就是根据 cvtres.exe 的返回值来判断其是否执行成功的。

调用栈中的 OurFileOpen() 函数,应该是负责打开文件的函数。在继续调试之前,先在 IDA 中看看 OurFileOpen() 函数的实现。

回到 IDA

双击 OurFileOpen,当然是直接查看 F5 的结果啦,有细节需要确认再看反汇编代码。

view-ourfileopen-in-ida-using-f5
view-ourfileopen-in-ida-using-f5

可以看到这个函数实现的非常简单,就是调用 _wfsopen(),如果失败(result == 0)那么调用 ErrorPrint() 打印错误信息。如果 open_mode(第二个参数)是 0,那么传递给 ErrorPrint() 的第一个参数是 1101,否则是 1108

而调用 OurFileOpen 时传递的第二个参数是通过 edx 传递的,对应的值是 0,所以如果出错,那么会传递 1101

view-ourfileopen-param
view-ourfileopen-param

说实话,看到 OurOpenFile() 函数中的 1101 ,我太激动了,因为在vs 中看到的错误提示是 error CVT1101: 无法打开“xxx.res”进行读取。为了进一步确认猜想,在 IDA 中查看 ErrorPrint() 函数的反汇编代码,如下图:

view-ErrorPrint-in-ida
view-ErrorPrint-in-ida

从上方红色高亮语句 CVTRES: fatal error CVT%04u: 基本可以确定猜测是正确的。从上图底部的红色高亮区域还可以知道该函数内部确实会调用 exit(1) 来结束进程。

接下来需要调查的问题是 _wfsopen 为什么失败了?

为什么 _wfsopen 会失败?

windbg 中输入 .restart 重启目标程序,输入 bp MSVCR120!_wfsopen,然后执行 g 命令。因为已经设置好了符号查找路径,所以 windbg 自动打开了对应的源码文件。

break-and-open-source-file
break-and-open-source-file

这个函数虽然很简单,加上注释不到 50 行。但是会被调用很多次,根据经验,前面的 500 多次调用都没有问题,在尝试打开 510.res 的时候会有问题,所以设置一个条件断点非常有必要。

简单查看反汇编代码发现,_wfsopen() 函数的第一个参数是通过 ecx 传递的,可以设置如下的条件断点(真是烧脑还不好理解,我不会告诉你,我尝试了很久才写出了下面这段蹩脚的脚本):

bp MSVCR120!_wfsopen "aS /mu $myFileName @ecx; .block {.echo $myFileName; r @$t0=$spat(@\"$myFileName\", @\"*510.res\"); .if(1==$t0){.echo **** bang ****} .else{ gc;} };"

耐心等待一会就中断下来了,如下图:

break-at-open-510res
break-at-open-510res

单步走两步,发现是 _getstream() 出错了。

_getstream 错在哪里了?

输入 .restart 重启目标程序,并且设置好条件断点,重新运行程序,当中断到 _wfsopen() 函数后,单步步入到 _getstream() 函数中。

view-getstream-in-windbg
view-getstream-in-windbg

可以看到 _getstream() 函数逻辑也不复杂,根据注释可以很简单的理解此函数的逻辑 —— 从 __piob 中(大小是 _nstream,通过 dt _nstream 可知其大小是 512)找到一条可用的记录项。判断一条记录项是否可用的标准是 __piob[i] == NULL ,或者 !inuse( (FILE *)__piob[i] ) && !str_locked( (FILE *)__piob[i] )

直接在函数末尾加好断点,g 起来,发现确实没有找到一条可用的记录项。

至此,我大概明白了整个过程。cvtres.exemain() 函数中会循环调用 ReadResFile() 函数(内部会调用 _wfsopen())读取所有的 .res 文件,但是读取完一个 .res 文件后,并没有关闭,当打开一定数量的文件后会导致 __piob 被占满。再尝试打开一个文件的时候就报错了。

看来,crt 还有最大打开文件数的限制,赶紧 google 搜索是否有什么设置可以调整最大文件打开数量。

google 一下

google 中输入 crt max open file 找到了几个相关的网址。

search-crt-max-open-file-in-google
search-crt-max-open-file-in-google

虽然可以通过 _setmaxstdio() 调整 crt 的最大文件打开数,但是好像不能通过修改配置文件或者修改注册表的方式调整。

发帖询问

说实话,第一次分析到这个结果的时候我是有些不信的。于是我再三确认了 ReadResFile() 函数内部确实没有关闭文件的操作。难道有什么特殊的理由不关闭打开的文件?但是我实在想不出有什么理由。所以我觉得这是一个 bug,于是我在微软官方论坛上发了一个帖子,希望能得到一些回复。

帖子地址是 https://docs.microsoft.com/en-us/answers/questions/709392/cvt1101-can39t-open-xxxres-for-reading.html

目前只有一位网友回复(另外一个是我自己),为了方便大家阅读,截图如下:

talking-thread-on-microsoft-q&a
talking-thread-on-microsoft-q&a

虽然到现在还没收到官方的确认回复,不过我依然认为这是一个 bug,而不是 feature

解决方案

既然没有设置选项或者配置文件可以简单的调整最大文件打开数量,对 cvtres.exe 打补丁又不太现实(每台机器上都要做处理),等待微软修复这个问题也不现实(远水解不了近渴)。所以我们的解决方案是通过合并一些 .rc 以减少工程中的 .rc 文件数量来规避这个问题。

虽然问题已经调查清楚了,但是还有几个问题值得探究。

几个值得深究的问题

  1. 为什么链接的时候需要调用 cvtres.exe 呢?
  2. 有没有更好的设置条件断点的方式?目前的语法实在是太难用了。
  3. 有什么简单的办法可以查看 __piob 数组中元素的内容吗?
  4. 为什么在打开 510.res 的时候就报错了?应该可以打开 512 个文件才对?

由于本篇已经太长了,下一篇文章中继续把残留的这几个问题解答。

总结

  • crt 有最大打开文件数的限制,可以通过 _setmaxstdio() 进行调整。
  • 在一个工程中最好不要同时包含太多 .rc 文件,一般应该不会遇到我遇到的这种情况。
  • 在不需要使用文件的时候,一定要及时关闭。
  • 进程退出后,依然可以使用 k 系列命令查看调用栈,有时候可以快速定位进程退出的原因。

参考资料

https://stackoverflow.com/questions/61581826/visual-studio-2019-cvt1101-lnk1123-fatal-error

https://docs.microsoft.com/en-us/cpp/build/reference/dot-res-files-as-linker-input?view=msvc-170

https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/setmaxstdio?view=msvc-170

vs2013 自带的 crt 源码

posted @ 2022-07-25 20:11  BCN  阅读(219)  评论(0编辑  收藏  举报