[原创]基于钉钉探索针对CEF框架的一些逆向思路
2022-8-29 01:30 9472
CEF 是 Chromium Embedded Framework 的简写,这是一个把 Chromium 嵌入其他应用的开源框架。
现在市面上有许多桌面软件都使用了CEF框架,比如我们经常使用的钉钉、网易云音乐等等。
我本意是突破钉钉的一些功能限制,结果发现钉钉使用了CEF框架,故开始对CEF框架做了一些浮于表面的探索。由于个人能力有限,如果文章中有什么错误之处,还望大家多多指教。
在开始正式开始之前,有必要先观察一下钉钉的安装目录,看看里面有哪些我们感兴趣的文件。
我电脑上的钉钉版本是6.5.30-Release.7289101
通过查看运行中的DingTalk.exe进程的映射文件锁定你电脑上目前运行的钉钉的目录(这个地方会发现有多个同名进程,我们随便选择一个)
有朋友可能要问为什么要通过这种方式确定目录,这其实是因为钉钉的安装目录下面一般都会存在两个版本的文件,一个是当前版本另外一个则是上一个版本。据我观察这两个目录下的文件结构基本一致。
我电脑上的钉钉目前就使用的是current
目录。
打开current
目录可以发现许多的资源文件和依赖库文件,其中对于本文来说最重要的文件是libcef.dll
和web_content.pak
。libcef.dll
是CEF框架的支持库,web_content.pak
则是钉钉缓存在本地的html、js、css
文件。web_content.pak
本质是一个zip
压缩文件,我们可以通过解压软件查看里面的内容
那么可以知道这个压缩文件是被加密了,解压的时候会让输入密码,后面会提到怎么获取密码。通过观察文件的名字也大致可以猜出这些文件的作用。
钉钉中使用CEF框架的区域主要在聊天框显示区域。
下面主要介绍三个方面的内容
CEF
框架部分API
和数据结构的介绍web_content.pak
文件解密CEF
框架内置的调试窗口另外提一嘴,在钉钉的安装目录下面我们还可以发现有cef_LICENSE.txt``duilib_license.txt
等license
声明,通过这些声明我们也可以获得一些信息,比如钉钉还使用了duilib
界面库。
既然钉钉使用了CEF
框架,那么学会简单的使用CEF
框架,了解相关的API
会使我们事半功倍。
根据官方库的指引,我们前往https://cef-builds.spotifycdn.com/index.html下载框架。
官方实现了C语言版本的CEF框架以及C++版本的CEF框架,其中C++版本的框架是基于C语言版本的二次封装。而我们需要的libcef.dll
就是C版本的框架。
在此处下载的文件包含了已经编译好的libcef.dll
,无需我们从源码编译libcef
库。
实质上从源码编译libcef
库并不容易,因为其中涉及到编译chromium,我猜这也是为什么官方会提供各种平台各种版本的库的原因吧。
在下载时我们需要先了解CEF的版本编号格式
格式解释如下
以cef_binary_104.4.25+gd80d467+chromium-104.0.5112.102_windows32.tar.bz2
为例,其中104.4.25
和104.0.5112.102
是CEF和Chromium的版本信息,gd80d467
是git commit
的hash
我们可以先看看钉钉使用的libcef.dll
是什么版本
这里发现一个很坑的点,就是Windows的文件属性显示不全,而且还不能拖开,也不能复制。
不过根据已经显示出来的内容,可以发现钉钉使用的libcef.dll
明显不是在官方提供的页面下载的。版本约定和官方的太不一样,git commit
是8位的,官方库可是只有7位。g2e1fb6b
,我尝试使用g2e1fb6
、2e1fb6b
等hash
在commit
列表中搜索也没有发现,只能猜测钉钉使用的libcef.dll
是自己从源码编译的,而且可能对源码做了一些修改吧。
同时我使用91.0.0
在下载界面搜索也没有发现相同的版本。后面的版本信息显示不全,得想个办法解决一下子,争取下载一个最接近的版本。其实这里有一个大坑,后面会提到。
其实文件属性的信息是存在于PE中的资源节中的,使用Windows系统提供的API或者自己解析都可以拿到相关信息。不过我是本着能不写代码就不写代码的懒人思想的。
一般这种库或者框架的动态库中都会提供函数查询版本信息,所以我浏览了一下libcef
的导出函数
在libcef
的导出函数中我发现了cef_version_info
这个函数,看名字就知道干什么用的了。
该说不说,官方提供了C++版本的文档,为什么不提供一个libcef
的api
文档呢?反正我是没找到。不过虽然没有文档,还是有源码和大量注释的。
这个函数的定义是这样的
1 |
|
我们再结合下面的信息
从反汇编很明显的看出来这是一个数组下标寻址
从源码得知不同的参数获取不同的信息,那么完整的版本信息存在于一个32字节的数组中
在内存窗口转到数组内存
我们缺少的是最后Chromium的版本信息,那么就是最后四个int。那么简单的拼接,得到5B.0.1178.A4
转成10进制 91.0.4472.164
。
搜索发现只有一个版本满足要求,那么就用这个好了,下载Standard Distribution,这个里面的文件是完整的,包含了框架代码和示例代码。
后面突然想起使用解析PE的格式的一些工具,也能很方便的查看资源信息。
我用CFF试了一下
将下载后的文件解压,使用cmake
生成vs
工程。然后使用vs编译。
这个时候编译成功了,当然可能会在编译的时候遇到一些错误或者警告,按照提示解决即可。
那么环境准备好了,我们需要去学习一些CEF框架的基础知识了,直接看示例代码或者直接看框架源码都不是那么容易的,可以先在网上找前辈取点经。
掘金小册-CEF 桌面软件开发实战
知乎专栏-CEF
最终的目标是实现钉钉聊天窗口的防撤回功能,基于这个目标,一步步的解决一些遇到的问题。
CEF可以从本地或者网络加载资源,一般来说桌面应用程序会将大部分需要用到的文件缓存在本地。
所以第一步就是需要找到资源文件的位置,这个不同的软件可能使用的资源文件的名称不太一样,存放的位置也不太一样。比如钉钉是放在安装目录下的,但是网易云音乐就没有放在安装目录下。
在钉钉登录页面附加DingTalk.exe
选择没有命令行参数的附加
选择这两个函数下断点cef_stream_reader_create_for_data
cef_stream_reader_create_for_file
这两个函数是CEF提供的两个操作文件数据的函数,返回值都是cef_stream_reader_t
结构体。
区别在于cef_stream_reader_create_for_file
的参数是文件路径cef_stream_reader_create_for_data
的参数是内存地址和大小,即内存中的文件数据。
这两个函数的声明和相关的结构体如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
|
断点下好之后,直接登录。
钉钉中没有使用cef_stream_reader_create_for_data
函数,使用的是cef_stream_reader_create_for_file
。
命中断点,观察参数/local_res/common_res.pak
/web_content.pak
/local_res/common_res.pak
文件中的内容
/web_content.pak
文件中的内容
到这就已经确定了资源文件的路径了。
不过需要注意的一点是,如果程序使用了cef_stream_reader_create_for_data
函数,那我们就不能从参数直接得到路径了。这个时候需要配合下面的方法使用。
直接在kernel32.dll.CreateFileW/A
和kernel32.dll.ReadFileW/A
下断点,观察函数的参数,如果觉得这样比较废手的话,可以使用行为监控软件比如微软的ProcessMonitor
,设置好过滤选项之后监控程序的文件操作。
如果资源文件被加密了,怎么解密文件。
思路其实很简单,程序运行时肯定会在某个时机解密数据,我们在相关API处下断点,逆向分析即可得到密码。
钉钉的资源文件是zip压缩加密,得到密码的方式有两个方向。
cef_zip_directory
写数据到zip文件cef_zip_reader_create
从zip文件读取数据
函数声明和相关结构体声明
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
|
需要特别关注的是cef_zip_reader_t
中的open_file
成员
1 2 3 4 5 6 |
|
参数中带有password
,那我们在这个函数下断点就可以得到密码了。
具体步骤如下
在钉钉登录页面附加程序,cef_stream_reader_create_for_file
函数下断点。
登录钉钉,在函数cef_stream_reader_create_for_file
参数是web_content.pak
路径的时候记住返回值,并给cef_zip_reader_create
下断点,程序继续运行。
cef_zip_reader_create
断点名命中,检查参数是否是上面记住的返回值
如果没问题断到则先让程序回到返回处,得到cef_zip_reader_t*
返回值0x25CF2940
。
在内存中按地址查看0x25CF2940
根据open_file在结构体中的偏移我们直接就可以找到函数地址,我直接数了一下偏移是0x30
,下标第12项,直接下断点,运行程序等待断点命中。
然后断点确实命中了,第二个参数就是密码。这里就不截图了,感兴趣的可以自己去试一下。
如果程序没有使用CEF框架提供的函数解密,那么上面说的方法就不行了。这种时候只能使用老办法,在CreateFileA/W
和ReadFileA/W
下断点,调试程序。
用这种方式也能得到密码,好奇的同学可以去试一下,可以在栈中发现密码。
最后提一嘴,这个密码钉钉是怎么计算出来的。我只能说这个算法是MD5,可以利用IDA分析安装目录下的MainFrame.dll
结合算法识别插件。不过我没有逆,有大哥逆过,感谢大哥,手动at大哥0xC5。
可以解密资源之后,我们就可以分析Js文件了。想让修改生效,有两种方式
直接替换文件非常简单,但是有个问题。这个方式不太稳定,据我观察钉钉会不定期的更新资源文件(这个更新不是指钉钉的升级),更新之后还得重新替换。
第二种方式的话,其实也不难。我们可以hook cef_zip_reader_t
结构体中的read_file
函数,并配合get_file_name
函数实现在内存中修改。
不过内存替换我也没有去尝试,这里只提供一种思路。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
改代码不是什么难事,难的是找到关键点。如果能开启Chromium本身的动态调试功能,那对于分析人员来说简直是如虎添翼。
在 cef_browser_host_t
结构体中有一个show_dev_tools
成员,可以用来开启调试窗口。cef_browser_host_t
对象可以通过cef_browser_t
的get_host
拿到。
get_host ``show_dev_tools
声明
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
cef_browser_t
声明,cef_browser_host_t
声明比较大,就不放上来了,可以自己去看头文件(include/capi/cef_browser_capi.h)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
|
我们通过注入DLL,HOOK CEF的事件处理回调函数,使用回调函数的struct _cef_browser_t* browser
参数,从而调用到show_dev_tools
。
以按键事件为例
(代码来自将js代码注入到第三方CEF应用程序的一点浅见 的评论区风铃i大佬的评论,我做了一些修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
|
这个有个需要注意的点,非常重要(还记得我上面说的大坑嘛)。我使用的库的版本和钉钉的不一致,那么上面代码中使用的结构体声明可能在不同版本会有不同。这意味着我们编译出来的DLL中结构体的偏移和钉钉中也可能不一致。
注意上面的第43行代码,调用show_dev_tools
1 2 |
|
在我实际测试中,show_dev_tools
的偏移和钉钉中就不一致。当时也是找了很久原因,一开始也没往这方面想,还以为是参数没传对,或者有什么对抗存在。最后在调试的时候和官方例子做了对比,才发现调用的函数都不是show_dev_tools
!
所以我最后改了一下43行的代码,show_dev_tools
偏移差了4个字节,用close_dev_tools
刚好对上。
1 2 3 |
|
在聊天框中F12,最后终于是开启成功。
最后还要说一点就是DLL注入的时机,我选择的是程序在登录框界面的时候。这个时候libcef.dll
已经加载,cef_browser_host_create_browser
函数也没被调用。
刀已经准备好了,可以试试刀锋了。
首先考虑消息撤回的时候大概发生了什么。
用户A点击撤回->触发Js点击事件->向服务器发送网络请求->服务器处理请求,向各个客户端发送消息
用户B收到撤回的请求->Js处理请求,最后修改页面元素
向服务器发送请求这里有两种可能,一种是直接在Js中发送请求,另一种是Js代码和C++代码通信C++来发这个请求。钉钉使用的是后者,因为在撤回的时候调试窗口的Network页面没有发现有网络请求。
所以防撤回的实现点有很多种,我这里主要尝试在Js层做防撤回。
设置好断点
撤回时断点命中,调用链出来了。阅读代码看看什么地方修改比较合适。
找了一圈,发现最顶层的调用处做消息过滤比较合适
修改代码如下,成功防撤回
这里调试的时候还会遇到一个问题--Js文件太大,调试窗口格式化代码的时候卡死了。
解决方法很简单,我们把在web_content.pak
中找到代码文件把该文件先格式化了,不用调试的时候去格式化,这样调试就不会因为格式化的原因卡死了。
CEF框架是一个开源的框架,而且钉钉也没有加入诸如反调试之内的对抗手段,研究起来比较容易,遇到的一些问题基本都解决了。最大的坑就在于库的版本问题,但是通过调试也能发现端倪。
最后可以思考一些防御的手段,比如:
可以进行的相关研究还有很多,无聊的时候玩玩也挺好,毕竟CEF框架的使用还是挺普遍的。
看雪招聘平台创建简历并且简历完整度达到90%及以上可获得500看雪币~
最后于 2022-8-29 01:31 被Learn Life编辑 ,原因: