pop pc 到底做了什么
如果想要了解更多细节,请阅读CPU手册。简而言之,CPU将栈顶的2个字节放入pc,接下来的一个字节放入csr,最后一个字节忽略,然后将sp增加4
注意:尽管CPU支持16个段,计算器只使用了2个, 所以只有lcsr和csr的最低位有意义,其他位永远是0。 除此之外,由于内存对齐的特性,pc寄存器的最低位永远是0
所以,这对实现“编程”有什么用途呢?
我们很容易发现,代码中存在大量的pop pc
(大多数函数的末尾都是pop pc
),这条指令会把栈顶的4个字节以上述方式放入pc寄存器中并执行。因此,如果我们控制好栈中的数据,就可以跳转到任意位置,执行任意位置的代码。
剩下的问题就是如何写ROP程序了
例子
我们来看看这个hackstring(适用于991ES PLUS):
52个任意字符 cv24 M 1 - 0 cv26 X - Int cs23 0 - cv24 M 1 - 0 cv26 cs4 - A 4 0 - ! cs32 0 - 20个任意字符
注意:cv指的是单位转换,cs指的是科学常数,后面数字是对应编号。
我们先对照符号表把它转化成16进制的形式:
?? ?? ?? ... (52个任意字符) ... ?? ?? ??
ee 54 31 ?? 30 f0 58 ?? 6a 27 30 ??
ee 54 31 ?? 30 f0 04 ?? 41 34 30 ?? 57 b6 30 ??
??的意思是这个位置的数值不重要。
这个hackstring如何运作?
首先,基本溢出会导致这100字节在内存中循环出现(具体原因在前几节中)
具体地说, 地址x (8154h <= x < 8e00h) 具有hackstring中第 (x - 8154h) mod 100个字节的值。
最终,根据计算,可知当关键的pop pc
执行时,栈顶的四个字节恰好是hackstring的第53-56字节。
也就是说,当pop oc
被执行后,pc=0x54EE,csr=1,CPU执行1:54EE处的代码,这个地方的代码是:
pop xr0 ; 154EE
pop pc ; 154F0
我们把类似于 1:54EE这样位置的代码叫做gadget,这是ROP领域的一个术语,这些地方往往位于某个函数的末尾。因为函数返回之前要将开头备份的寄存器的值还原,所以存在大量的类似于上面这样的位置,可以实现在ROP中控制寄存器的值,甚至向某个内存单元中写入特定的值等操作。
计算器将栈顶的4个字节pop到xr0中,然后将栈顶的4个字节pop到pc中(注意每次从栈中弹出值都会使sp自动增加,指向后面的值),现在er0=0xF030 r2=0x58
,CPU又把6A 27 30 ??这四个字节放到pc寄存器中,即CPU又跳到了0:276A处执行代码:
st r2, [er0] ; 0276A
pop pc ; 0276C
接下来就不说了,跟前面一样。关键处在这hackstring的最后8个字节:
41 34 30 ?? 57 b6 30 ??
.
要理解这8个字节,你得了解nX/U8函数调用的规范。
首先,对于会调用子函数的函数,我们知道它们以push lr
开头,以pop pc
结尾
这是一些函数的位置及功能(991ES PLUS)
-
0:343Eh
: 一个函数, 接受一个地址er0和一个数字r2, 输出位于地址er0处的r2行字符 -
0:B654h
: 一个函数,不接受参数也不返回值 (void f(void)
),闪烁光标,并等待用户按下SHIFT键后返回。
这8个字节
41 34 30 ?? 57 b6 30 ??
按顺序调用了这两个函数
注意我提到了多行打印的函数位于0:343e,为什么我要跳转到0:3441呢?
首先,实际pc的值其实是3440(内存对齐),选择41只是因为输入方便而已。
现在,假设我们跳转到了0:343E,那么CPU会先执行push lr
,最后函数末尾执行pop pc
时,pc
的值会被设定为最开始入栈的lr,但这并不是我们想要的结果(我们无法操纵lr
的值)。我们的目的是想让CPU继续把栈顶的值弹出到pc
中,以便继续控制程序流。
因此,我们跳转到push lr
后的一条指令,而不是函数开头。这样就可以避免函数保存lr,最后pop pc
就会继续弹出栈顶的值到pc寄存器中,从而继续控制程序流。
(总结:跳到紧跟着push lr
后的那条指令大多数情况下都是正确的,但在某些情况下,也可以有别的选项)
- 如果我们跳转到
push lr
前面一条或若干条指令,那么程序往往会执行到一些无法预料的地方,因为push lr
前面往往是另一个函数的末尾,即pop pc
或rt
。 - 如果我们跳转到
push lr
指令,那么最后在pop pc
时,CPU会跳转到开始时lr中的地址执行,这只有在我们能够控制寄存器lr
的内容时才可以控制程序流。
一个类似的情况是:如果我们跳转到一个以rt结尾的函数开头或中间。我们也需要保证寄存器lr的值是可控的。 - 如果我们跳转到
push lr
后的一条指令,CPU会执行完函数本身,然后弹出栈顶的4个字节到pc寄存器中。这是最理想的情况。 - 如果我们跳转到
push lr
后的若干条指令,CPU会执行函数体的大部分指令,除了开头的几条指令(往往是备份寄存器),这往往会破坏栈平衡,因此需要仔细研究。
一般情况下我们在调用函数时只使用选项3,在某些情况下也会用到2和4,这些例子会另行说明。
循环
唯一在ROP中实现循环的方法就是修改sp的值,如果你在源代码中搜索sp,你会发现修改了sp的值而又离rt
或pop pc
足够近的指令只有:
-
mov sp, er14
(后面往往是pop er14
和pop pc
) -
add sp, #...
(正数表示从栈中弹出相应字节)
目前只有第一种用于循环:
所以,为了循环我们需要:
- 保证没有执行一些会破坏栈的函数(几乎所有函数都会使用到栈用作临时存储)
- 执行gadget
pop er14; pop pc
然后把 A-2 放在这个gadget的后面(A就是sp的新值). - 执行gadget
mov sp, er14; pop er14; pop pc
.
(在有函数破坏了栈的情况下,循环依然是有可能的,在后面会提及)
当最后一个gadget mov sp, er14; pop er14; pop pc
被执行时,发生了:
-
mov sp, er14
: sp 的值变为 A - 2。 -
pop er14
: sp 增加 2 字节. er14 就是那两个字节的内容(不重要)。 -
pop pc
: pc 的值现在变成了 A处的4个字节。
PS:可以认为这一串10个字节一起构成了goto A
这样一个gadget。
例子:
<52个任意符号> cv24 M 1 - Fvar cv26 cv40 - Int cs23 0 - tan-1 D 0 - cs26 cv26 cs16 D 1 - cv12 = 0 - sin 2 0 - 0 cv34 Int cs23 0 - (-) cs32 0 - frac Ans ^ cs32 0 - <后面填满100个字节>
16进制表示:
?? ... (52 bytes) ... ??
ee 54 31 ?? 46 f0 fe ?? 6a 27 30 ?? b2 44 30 ?? 40 f0
1d 44 31 ?? e2 3d 30 ?? a0 32 30 ?? 30 f8
6a 27 30 ?? 60 b6 30 ?? ae 8b 5e b6 30 ??
?? ??
只有最后10个字节实现了循环
60 b6 30 ?? 跳转到 0:b660h。 从 0:b660h 到 0:b662h 的指令被执行。
接下来,ae 8b 被弹出到 er14. (也就是0x8BAE)
5e b6 30 ?? 跳转到 0:b65eh。与上述过程类似,只不过多执行了mov sp, er14
。
含有修改栈的循环
注意:
-
pop
指令只会增加sp的值,不会修改栈本身 -
push
指令大多数时候都会修改栈的内容,只有当被push
的值能够确保恰好跟栈顶前的值相同的时候才不会修改栈的内容。(包括push lr
)
所以,要在这种情况下循环,我们需要在之前的循环方式之前加上还原堆栈的部分
大多数时候,我们通过调用strcpy
函数还原栈,在前面我们已经知道内存中全部是输入区100字节的拷贝,那么只要随便把一个拷贝还原到刚才的地方就可以了。