在ES PLUS系列计算器上实现“编程”——(十)构造hackstring

pop pc 到底做了什么

如果想要了解更多细节,请阅读CPU手册。简而言之,CPU将栈顶的2个字节放入pc,接下来的一个字节放入csr,最后一个字节忽略,然后将sp增加4

注意:尽管CPU支持16个段,计算器只使用了2个, 所以只有lcsrcsr的最低位有意义,其他位永远是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=0x54EEcsr=1,CPU执行1:54EE处的代码,这个地方的代码是:

    pop xr0                        ; 154EE
    pop pc                         ; 154F0

我们把类似于 1:54EE这样位置的代码叫做gadget,这是ROP领域的一个术语,这些地方往往位于某个函数的末尾。因为函数返回之前要将开头备份的寄存器的值还原,所以存在大量的类似于上面这样的位置,可以实现在ROP中控制寄存器的值,甚至向某个内存单元中写入特定的值等操作。

计算器将栈顶的4个字节popxr0中,然后将栈顶的4个字节poppc中(注意每次从栈中弹出值都会使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后的那条指令大多数情况下都是正确的,但在某些情况下,也可以有别的选项)

  1. 如果我们跳转到push lr前面一条或若干条指令,那么程序往往会执行到一些无法预料的地方,因为push lr前面往往是另一个函数的末尾,即pop pcrt
  2. 如果我们跳转到push lr指令,那么最后在pop pc时,CPU会跳转到开始时lr中的地址执行,这只有在我们能够控制寄存器lr的内容时才可以控制程序流。
    一个类似的情况是:如果我们跳转到一个以rt结尾的函数开头或中间。我们也需要保证寄存器lr的值是可控的。
  3. 如果我们跳转到push lr后的一条指令,CPU会执行完函数本身,然后弹出栈顶的4个字节到pc寄存器中。这是最理想的情况。
  4. 如果我们跳转到push lr后的若干条指令,CPU会执行函数体的大部分指令,除了开头的几条指令(往往是备份寄存器),这往往会破坏栈平衡,因此需要仔细研究。

一般情况下我们在调用函数时只使用选项3,在某些情况下也会用到2和4,这些例子会另行说明。


循环

唯一在ROP中实现循环的方法就是修改sp的值,如果你在源代码中搜索sp,你会发现修改了sp的值而又离rtpop pc足够近的指令只有:

  • mov sp, er14 (后面往往是pop er14pop 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:b660h0:b662h 的指令被执行。
接下来,ae 8b 被弹出到 er14. (也就是0x8BAE)
5e b6 30 ?? 跳转到 0:b65eh。与上述过程类似,只不过多执行了mov sp, er14


含有修改栈的循环

注意:

  • pop指令只会增加sp的值,不会修改栈本身
  • push指令大多数时候都会修改栈的内容,只有当被push的值能够确保恰好跟栈顶前的值相同的时候才不会修改栈的内容。(包括push lr)

所以,要在这种情况下循环,我们需要在之前的循环方式之前加上还原堆栈的部分

大多数时候,我们通过调用strcpy函数还原栈,在前面我们已经知道内存中全部是输入区100字节的拷贝,那么只要随便把一个拷贝还原到刚才的地方就可以了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。