by amama
I ran the reverse
program and saw input nothing, so I hit some keys then the program suddenly finished with the word 'wrong'.
I guessed the program is getting a line, and checking the line is the same as the flag, then print 'correct' or 'wrong' (all of these 'rev' problems the kind like this, isn't it?).
Next, to debugging the reverse
program, check symbols in the program using objdump
command (I know, usually we use readelf
instead of objdump
in general, but in my case, I just want to know the entry point of the program).
objdump -d reverse
Now we can get reverse
function and _start
functions, and the latter is the entry point.
It is time to debugging!
I prepared to run the reverse
program again with gdb
, and set breakpoints to the _start
function and the reverse
function, then ran reverse
and the program stoped in entering _start
function.
Using stepwise instruction execution (command si
), you can see the mechanism of the program. Surprisingly, the program repeat executing an instruction and calling the reverse
function again and again! How does the reverse
function work? Let's disassemble the reverse
function.
The core of the reverse
function is below.
Execute these instructions in your mind or gdb, now you can understand these instructions literally 'reverse' the code. So, the next instruction after the reverse
function executed is the bottom binary of the code area.
Recalling the reverse
function's code and continue to debug, you realize that important instructions are the instruction after the reverse
function. The core part of the _start
function does
Parts 1 and 3 are not important to solve the problem, so we pay attention to part 2.
Part 2 decrypts flag and compares to the input, therefore we simulate the sequence of decryption and get the decrypted flag. The detail of part 2 is a simple arithmetic operation.
let input[37] be an input of the program (the number, 37, is easily seen in the resister when syscall instruction is executed).
We can see the suquence like the Pseudo code.
Pseudo code is:
Hence, using array1 and array2 and apply inverse projection of the operation, now we get the flag.
Dumping array1 and array2 using dgb (the addresses of array1 and array2 in the run-time are both 0x600194
but inside of array is actually different because reverse
oparation reverses around these addresses), get these values.
decrypt code is here:
Note: It is important to swap adder
and xorer
because these instruction's number is odd, reverse
function reverses the code one by one.
Actually call reverse
is injected between every instruction.
by amama
If you cant read Japanese, use DeepL or Google Translation
TL;DR Quine書いた。終わり
プログラマが行う知的な遊びの一つとして、自分自身を出力とするプログラムを書く、というものがある。
これはQuineと呼ばれており、インターネットで検索するといろいろなQuineのソースコードを見ることができるだろう。
では、Quineは一体どうやって書けばいいのだろうか。
ここでは、一般的なプログラミング言語に使える技術を紹介するとともに、それをコンパイラに応用する方法を示す。
さて、実際にQuineを書くにあたって、ここで目指すQuineの一般形をここに示しておこう。
これはC言語風の疑似ソースコードだが、Quineの本質部分は十分理解できるはずだ。
では、このソースコードに到達するための道のりを説明する。
まずプログラマ諸氏がナイーブに「自分自身を出力するプログラム」を書こうと思ったとき、上記のようなプログラムを書こうと思う人は居ないはずだ。
以下のようなプログラムを書こうとして、無限後退に陥っていることに気づくだろう。
当然、上記のアプローチはうまく行かない。
見てもらえば分かる通り、このプログラムの問題点はs
に「自分自身」の文字列を格納しようとするが、実際の出力とソースコードがどうやっても一致しないため、どんどん文字列が長くなってしまうことにある。
この問題点を解決するアプローチの一つとして、文字列の中に「自分自身」を表す文字を入れ、その文字が来たら自分自身を出力する、という方法がある。
このdecode
関数による出力は自分自身と同じ文字列を出力するように見える。
しかし、注意深い読者なら直ちに気がつくであろうが、実は2箇所違う場所がある。
このバージョンはdecode
関数の引数の文字列に「"
」を含むため、当該箇所(…… decode(\"@\");");
)を「\"
」としているが、「\"
」のプログラム中における値は「"
」であるため、出力された文字列をよく見ると、…… decode("@");");
となってしまっている。
この問題を解決するため、decode
関数を改良する。
最もナイーブな解決方法としては、
とすることであるが、この方法はうまくいかない。
なぜなら、肝心のdecode
関数に渡す文字列の中に「"」を出現させない、という目標が達成できていないからだ。
たしかに、…… decode(\"@\");");
は…… decode(@);");
と書けるようになったが、今度は関数の中身を文字列化するときにcase 64:print(s);
がcase 64:putc('\"'); print(s);putc('\"');
となってしまう。
このように、ソースコード中に「"
」を出現させずにコードを書く必要があるが、その値に対する複数の表現方法があれば問題点を回避できるような表現方法を選択することによって解決できる。
たとえば、大抵のプログラミング言語ではある文字とそれを表現するASCII/unicodeは互いに変換可能なことが多いので、これを利用して「"
」のASCIIコードである34を代替表現として用いることでdecode
関数が完成する。
Quineを書くときには、「文字列中に存在してはいけない文字」が出てこないように注意深くコードを書くことが必要である。
つまり、Quineを書くためには、
の2つがあればよいということがわかる。
次に、Quineを応用してコンパイラに悪意のあるコードを侵入させる方法について示す。
この問題の目標は「「「攻撃者のコンパイラでコンパイルしたcompiler.x」でコンパイルしたcompiler.x」でflagの1文字目を出力するコードをコンパイルしたとき、すべての文字列を出力するプログラムを出力する」ということである。
コンパイラを使用してプログラムをコンパイルするとき、そのコンパイラにバグがあったら成果物が正しく動かない。そこで、コンパイラにバグが有るかどうかを判断したい、という問題がある。
この問題を解決するため、3-stage bootstrapという手法が使用される。
まず、言語Xで書かれた言語Xのコンパイラのソースコード にバグがあるかもしれない、と仮定する。
そして、 を既存のコンパイラ でコンパイルして成果物であるコンパイラ を得る。
しかし、 にはバグがあるかもしれないので は正しく動かないかもしれない。
すなわち、 でコンパイルした成果物が正しく動かないかもしれない、ということである。
このコンパイラで自分自身 をコンパイルすると、「正しく動かないかもしれないコンパイラでコンパイルされたコンパイラ 」が手に入る。
もし に
さて、このどちらかのコンパイラが生成されるわけだが、当然、正しく動くコンパイラを使用したい。
では、どうやったら見分けることができるのだろうか?
例えば、これによって得られた で同じソースコード をコンパイルするとどうなるか考えてみよう。
結果は、 でコンパイルされたコンパイラ か、でコンパイルされたコンパイラ となる。
もし が正しく動いていれば、 と を比較すると一致するはずである。
そして、この対偶を取ると、「もし と を比較して一致しなければ、 は正しく動いていない」ということがわかるので、 と が一致するならば(成果物の生成に対しては)
正しく振る舞うようなコンパイラが書けた、と言える。
ちなみに と を比較しても、コンパイルしたコンパイラのソースが違うため( を出力したのはで を出力したのは からできたコンパイラである)、意味がないことに注意してほしい。
CTFに参加する諸君はインターネットからソフトウェアやライブラリの実行可能バイナリをダウンロードして利用することはせず、対象となるOSSのソースコードを読み込み悪意のあるコードが混入していないか検査してからコンパイルして利用することが一般的であると思うが、そのコンパイラに悪意がないとどうして言い切れるのだろうか?
コンパイラにバグがあるかを判断する手法として3回コンパイルするという手法があることを説明したが、悪意のあるコードが挿入されているかどうかを判断することはこの方法では不可能であることを同様の論法を持って紹介する。
まず、言語Xのコンパイラ に悪意のあるコードがあるかもしれない、と仮定する。
そして、事前の検証によって安全であることがわかっている「言語Xで書かれた言語Xのコンパイラのソースコード 」を でコンパイルして成果物であるコンパイラ を得る。
しかし、 には信用できないので も信用できない。
すなわち、 でコンパイルした成果物に悪意のあるコードが混入されているかもしれない、ということである(例えば、成果物に対してある種のバックドアを仕込むようなものかもしれない)。
このコンパイラで自分自身 をコンパイルすると、「信用できないコンパイラでコンパイルされたコンパイラ 」が手に入る。
もし に
さて、このどちらかのコンパイラが生成されるわけだが、信用できるかどうかを見分けられるだろうか?
同じように、これによって得られた で同じソースコード をコンパイルするとどうなるか考えてみよう。
結果は、 でコンパイルされたコンパイラ か、でコンパイルされたコンパイラ となる。
バグの有無を判断するときには、「もし と を比較して一致しなければ、 は正しく動いていない」ということを判断基準にしたが、この場合、悪意のあるコードが「『入力 に対しては のように振る舞う』というコードを挿入する」のようなものであった場合、 に「入力 に対しては のように振る舞う」 というコードが挿入されてしまうため、 と は常に一致してしまう。
よって、この方法ではコンパイラに悪意があるかどうかを判断することができない。
では、どうやってこの悪意のあるコード挿入を達成すればよいのだろうか。
一般にコンパイラとは「入力として言語Xのソースコードを受け取り、言語Yのソースコードを出力するプログラム」とみなすことが多いが、ここでは更に一般化して、「入力xを受け取り、yを出力するプログラム」であると解釈する。
ここで、「言語Xで書かれた『言語Yを受け取り言語Zを出力するプログラム』」を と表すことにする。
をに入力として与えると、出力として が手に入るが、もし
であればこれはある入力()に対してQuine的に振る舞う、ということがわかる(すなわち、出力と自分自身が一致しているということである)。
さて、単純なQuineが書けることはすでにわかっているが、このような「ある入力に対してQuine的に振る舞うプログラム」に対してこの技術を応用したい。
この問題の目標は「「「攻撃者のコンパイラでコンパイルしたcompiler.x」でコンパイルしたcompiler.x」でflagの1文字目を出力するコードをコンパイルしたとき、flagのすべての文字列を出力するプログラムを出力する」であるので、
code
が 攻撃者のコンパイラcode2
が「攻撃者のコンパイラでコンパイルしたcompiler.x」code3
が「攻撃者のコンパイラでコンパイルしたcompiler.x」でコンパイルしたcompiler.xである。
また、
とする。
つまり、code3
で をコンパイルしたとき、 を出力せよ。
という問題である。
よって、code3
は、
code2
は、
compiler.x
の場合、『 を出力する』というコードを挿入する」という悪意のあるコードが挿入されたコンパイラであり、code
は、
compiler.x
の場合、『入力が compiler.x
の場合、【 を出力する】というコードを挿入する』というコードを挿入する」コンパイラである。そして、problem.pyには code2 == code3
を確認するチェックが61行目にかかれているため、code2
は本質的にcode3
と同じことができなければならず、逆もまた然りである。
よって、code3
は
compiler.x
の場合、『 を出力する』というコードを挿入する」という悪意のあるコードが挿入されたコンパイラであるそして、code3
を出力する code2
は
compiler.x
の場合、『 を出力する』というコードを挿入する」という悪意のあるコードが挿入されたコンパイラであるので、code2
は compiler.x
を入力としたとき、Quine的に振る舞うことがわかる。
以下、code2
のソースコードを compiler2.x
として説明する。
compiler2.x
は、
compiler.x
ならば、入力を compiler2.x
とみなす」というコードになっている上の条件はまさしくQuineと同様の手法を使ってコードを書くことができる。
また、ここでは簡単のため、上記の機能を実装するときに入力をすべてチェックすることはせず、flagが入手できる程度にcompiler2.x
が書ければ良いものとする。
「入力が compiler.x
ならば」という部分は
s = tokenize()
が終わったあとに、main
や tokenize
などといった識別子が s
の中に存在しているかで判断すればよいだろう。
さて、「入力を compiler2.x
とみなす」という部分はどのように実装すればよいのだろうか。
compiler2.x
のコードをそのまま文字列にすると無限後退が起きてしまうということは学習したので、Quineと同様に
compiler2.x
とみなすプログラムcompiler2.x
とみなすプログラム」をエンコードした値を実装すればよい。
「入力を compiler2.x
とみなすプログラム」を実装したものが以下である。また、コード中に出てくる qlist
は「入力を compiler2.x
とみなすプログラム」をエンコードした値が入っている。
inj_ret = inj_ret + escape ( qlist ) ;
の部分が、自分自身を出力する行為に相当する。
このコードをエンコードして即値として qlist
に代入すれば、「入力が compiler.x
ならば、入力を compiler2.x
とみなす」というコードができる。
実際は、同時に「入力が の場合、 を出力する」というコードを挿入するコードにでもなければならないので、以下のコードを injection
関数に追記する。
これは、「入力が の場合、 を出力する」というコードをエンコードしたものになっている。
また、これらのコードはQuineを書くときに注意しなければならない「文字列中に存在してはいけない文字」である「"
」が出現してしまっているので、エンコードするときに "main"
などという文字列は代替表現である [109, 97, 105, 110]
になおしてからエンコードする。
例えば、inj_ret = inj_ret + [ "]" , ";" ] ;
というコードをエンコードするときには、"
が入っていない等価なコードであるinj_ret = inj_ret + [ [93], [59] ] ;
とみなしエンコードする。
エンコード後は
[[101, 110, 106, 95, 114, 101, 116], [61], [101, 110, 106, 95, 114, 101, 116], [43], [91], [91], [57, 51], [93], [44], [91], [53, 57], [93], [93], [59]]
というリストとなり、これは
[ "inj_ret", "=", "inj_ret", "+", "[", "[", "93", "]", ",", "[", "59", "]", "]", ";"]
という表現と等価である。
エンコードの際に用いたスクリプトが以下である。
このスクリプトは、空白区切りでtokenizeされた文字列を受け取り、エンコードした値を返す。
エンコードした値は、文字列のリストであるので、数値のリストのリストが出力される。