yyy

CTFつよくなりたい

セキュキャン2017 応募用紙

はじめに

合格を頂くことができました.年齢的に今年が最初で最後の応募だったので,応募開始してから死ぬ気で埋めていた甲斐がありました.応募用紙に関しては,落ちても受かっても晒そうと思っていたので晒します.

感想として,学ぶことがとても多かったと思います.GWに実家に帰省してからも問題に取り組んでいて,大学の休み時間や家に帰ってきてからまた取り組んだり,とにかく空き時間はほとんど費やしていた気がします.選択問題A-6で1週間使ってなんとか実装し終わったと思ったら,選択問題A-5が激ムズで問題変えようか悩んだりと,そんな感じでした.

少しでも,来年以降応募する人のためになれば幸いです.

(共通問題2とかイキリまくってて恥ずかしいので共通問題は省略します)

共通問題1

高校生の時に作った自作PCやバイト先で書いたプログラム,個人で制作したものなどを10個書きました.どうして作りたいと思ったか,なぜ作ることになったか,という動機と背景をしっかり書きました.

共通問題2

1つ目は自分の無知さやプログラミング力の無さを実感したこと,2つ目はCTFで全く問題が解けなかったことを書きました.アドバイスは本当にその人に向けて書くように文体も変えて書きました.

共通問題3

受講したい講義は7個ほど挙げました.それぞれの講義で「どうして受講したいのか」と「なにを学びたいのか」を書きました. セキュリティキャンプでやりたいこととしては,「今後何をやっていきたいかを考える為のきっかけを作りたい」ということと「同じ志を持った人達と交流を深めたい」ことを書きました.

共通問題全体で2万5千字ぐらいでした.

選択問題A-4

まずは、printf()関数についてです。一言で言うならば、問題文にもある通り「数値や文字列を表示する関数」ですが、その表示する過程にどのようなことをやっているのか、わからなかったので調べました。まず以下のようなプログラムを書いてみました。

#include <stdio.h>

int main(){
printf(“hoge\n”);

return 0;
}

これは、"hoge"という文字列を表示する最もシンプルなプログラムです。このプログラムの処理をgdbというデバッガで追いかけてみることにしました。私は普段、“peda"というgdbの機能をより高めてくれるツールが導入されたgdbを使っているので、以下の実行例はpedaが導入されたgdbで行っています。また、環境はUbuntu16.04の64bitです。64bitだと、レジスタが拡張されます。今回の目的はprintf()関数について調べることなので、もっとわかりやすい32bitプログラムで調べるために”-m32"というオプションを付けてgccコンパイルしました。startというmain関数にブレークポイントをしかけてから開始することができるコマンドを実行すると、

0x8048419 <main+14>:   sub esp,0x4

という行で止まりました。これは、ESPというスタックポインタであるレジスタから、0x4を引き算しているという命令ですが、これから使うスタックの領域を確保するためにこのようなことを行います。スタックは下方伸長なので、アドレスが低位に向かって伸びるため、足し算ではなく引き算を行います。スタックポインタはスタックの先頭を現しています。niというステップアウト実行できるコマンドを行ったところ、

0x804841c <main+17>:   sub esp,0xc

また、スタックから0xcだけ領域を確保しています。さらに進めます。

0x804841f <main+20>:   push 0x80484c0

0x80484c0というアドレスをスタックにpushしています。実行するとESPの値が以下のようになりました。

ESP: 0xffffbf50 --> 0x80484c0 (“hoge”)

0x80484c0という場所に"hoge"という文字列は格納されているのかなと思い、

x/1wx 0x80484c0

という風にして、0x80484c0のアドレスから4バイトで1個のメモリを16進数値として調べてみると、

0x80484c0:   0x65676f68

このように、確かに0x80484c0の場所に0x65676f68という値が格納されていました。ASCIIコードで65は"e"、67は"g"、6fは"o"、68は"h"であり、逆順に格納されているのはリトルエンディアンという方式を使っているからです。再びniで次の命令である、

0x8048424 <main+25>:   call 0x80482e0 puts@plt

を実行してみると、"hoge"という文字列が表示されました。

ここである疑問が湧きました。ソースコードではprintf()関数を使っていたのに、なぜputs()関数に変わったのかということです。すると、このサイト( http://0xcc.net/blog/archives/000065.html )で答えを見つけることができました。どうやら、文字列の末尾が"\n"で終わり、%d などのフォーマット指定がないときは最適化が行われ、puts()関数が使われるみたいです。今回使ったソースコードでは、printf(“hoge\n”);という使い方をしており、まさに最適化に含まれる対象だったので、puts()関数に変わったということがわかり、勉強になりました。しかし、gccで"-O0"というオプションをつければ、最適化は無効化されるはずが、これでコンパイルしてもputs()関数に変わってしまったので、オプションより強い制約が内部でかかっているのかなと考察しました。printf()関数を調べるために、改行文字を省いたプログラムを再度コンパイルし、gdbで実行しました。すると、今度は最適化に含まれる対象ではなかったようで、printf()関数が呼ばれていました。このprintf()関数の中に入り、さらに処理を追っていこうと思います。今までステップアウト実行であるniのみで進めてきましたが、ステップインするsiというコマンドを使って、実際に関数の中に入っていきます。すると、

0x80482e0 printf@plt:    jmp DWORD PTR ds:0x804a00c

という場所に飛びました。なぜこのpltという領域に飛ぶのかを説明するために、静的リンクと動的リンクの話をします。

プログラムがコンパイラによってコンパイルされるとき、リンカによって静的リンクか動的リンクのどちらかのリンク方式により、共有ライブラリがリンクされます。静的リンクの場合は、リンク時に実行ファイルに共有ライブラリをリンクしてしまい、実行ファイル内に内蔵してしまう形式です。なので、ファイルサイズがその分大きくなります。対して動的リンクの場合は、リンク時に実行ファイルに直接共有ライブラリをリンクするのではなく、どれを使うかという情報のみをリンクさせます。なので、後でシンボル解決と呼ばれる行為を行う必要があります。起動時には、動的リンクより静的リンクのほうが時間がかかってしまいますが、それぞれにメリットがあります。この実行ファイルをLinuxのfileコマンドを使って調べると、“dynamically linked"の文字列が確認できるので、動的リンクだということがわかります。なので、動的リンクの話を続けます。動的リンクでは、共有ライブラリの中にある使いたいものをPLT領域を経由して呼び出します。なぜそのようなことを行うかというと、動的リンクの場合共有ライブラリは実行時にリンクされます。なので、共有ライブラリのアドレスを調べてシンボル解決する必要があり、そのための領域としてPLT領域とGOT領域が存在します。まずはPLT領域ですが、シンボル解決をするために、さらにPLTからGOT領域へ飛びます。PLT領域を"objdump -d -M intel -j .plt filename"で逆アセンブルしてみます。私はintel形式のほうが好きなので、”-M intel"オプションをつけています。

080482e0 puts@plt:
80482e0: jmp DWORD PTR ds:0x804a00c
…

0x804a00cという場所には以下のように、

x/1wx 0x804a00c
0x804a00c:  0x080482e6

0x80482e6が入っているので、これはそのまま次の命令に進み、0x0をスタックにpushし、最終的に0x80482d0に飛ぶようになっています。

080482d0 printf@plt-0x10:
80482d0: push DWORD PTR ds:0x804a004
80482d6: jmp DWORD PTR ds:0x804a008
…

0x8048ed0に飛んだ後は、最終的に2行目のjmp命令で、0x804a008というアドレスに飛びますが、このアドレスが指している場所がGOT領域です。なぜわかったのかというと、"readelf -S filename"でセクションヘッダがわかるのですが、

[24] .got.plt PROGBITS 0804a000 001000 000014 04 WA 0 0 4

その結果の内の.got.pltの開始アドレスが0x0804a000になっていてこのセクションに含まれていることがわかるからです。GOT領域では、プロセスごとに独立している共有ライブラリへの参照を保持します。関数呼び出しを行う際に、呼び出す本体へのポインタを別のところで保持しておき、ポインタ経由で本体である関数を呼び出すということをやっています。このように、PLT→GOT→共有ライブラリの順番で処理しています。2段階になっているのは、シンボル解決のためであり、そのためにPLT領域で準備をしています。いつシンボル解決を行うかですが、関数が最初に呼ばれた時に行います。最初、GOTにはシンボル解決を行う為の処理のアドレスが入っており、それが完了すると、2回目以降からは本体を直接参照するようになります。これは遅延リンクと呼ばれ、実際に使わない関数はシンボル解決を行わないので、起動時間を短縮できます。

話が少しそれますが、GOT領域を利用した攻撃手法にGOT Overwriteというものがあります。その名の通り、GOT領域を上書きする攻撃です。最近のコンパイラには、RELROというセキュリティ機構があります。これはRELocation ReadOnlyの略で、マッピングされるデータのどの部分にReadOnly属性を付けるかを決定します。ReadOnly属性を付けることで、悪意のあるコードが注入されたときに、値を書き換えられて制御を奪われてしまう行為を防ぐことができます。プログラム起動時に上で述べたシンボル解決をしてしまい、GOT領域を読み込み専用にしてしまうFull RELRO以外のNo RELRO、Partial RELROの場合はGOT Overwriteを行うことができます。書き込み可能になっているGOT領域を書式文字列攻撃で書き換えると、そのコードが実行された時に、書き換えた値へと制御を移すことが可能です。上手くやればシェルまで奪えてしまうので、起動時間が遅くなってしまうというデメリットがありますが、セキュリティリスクを減らすためにもFull RELROにしておくほうがいいかと思います。ここら辺の知見はksnctfというサイトのVillagerAという問題を解いた時に得ました。

話を戻します。動的リンクの場合はこのようにPLT領域やGOT領域が絡んでくるので、純粋にprintf()関数について調べたい時には邪魔だと思うので、リンク方式を静的リンクに変えます。"-static"オプションを付けて、再度コンパイルしてgdbで解析します。printf()が呼ばれている場所でステップインすると、以下の場所に飛びました。

0x804ebe0 <printf>:    sub esp,0xc

どうやらprintf()の内部へと入れたようです。さらにステップ実行していくと、

0x804ebf5 <printf+21>: call 0x807e190 <vfprintf>

vfprintf()とはなんなのか、C言語の関数だと思うのでとりあえずmanコマンドで調べてみました。

The functions vprintf(), vfprintf(), … are equivalent to the functions printf(), fprintf(), …

という記述が見つかり、他の説明も読む限り大体は同じで引数の渡し方とかが違うのかなと思いました。vfprintf()をさらに追ってみます。文字列を出力している実態のようなものがないか、ひたすらステップ実行していったのですが、実行されてそうなところが見つからないので、おかしいなと思い、最初に戻りprintf()関数をステップインせずに実行してみました。しかし"hoge"という文字列が表示されません。使っているpedaのバグかと思いましたが.gdbinitを書き換えてgdb単体で実行してみるもやはりダメでした。これはおかしいなと思いながら、最後まで実行してみると、exitが呼ばれた時と同時に"hoge"という文字列は表示されていました。改行を使っていなく4文字しか表示していないため、表示の際のバグが起きているのかと思い、出力する文字列を"hoge\nfuga"としてみました。すると、printf()関数を実行したときに"hoge"、exitしたときに"fuga"という風に出力されてしまいました。少し気持ち悪いですが、その場で表示されないといつ表示されたかがわからないので、"hoge\nfuga"という文字列で再度コンパイルして続けます。vfprintf()まで進めます。ステップ実行していくと、

0x807e23c <vfprintf+172>:  call DWORD PTR [eax+0x1c]

この場所で"hoge"が表示されました。この関数にステップインして続けます。すると_IO_new_file_xsputn()という関数に入りました。やはりcall命令が怪しいので、そこに注目して続けます。

0x805211c <_IO_new_file_xsputn+204>:   call 0x8053810 <_IO_default_xsputn>

で"hoge"が表示されたのでまたステップインします。ループしている箇所があり、4回ループして"hoge\nfuga"の内の改行文字"\n"を引数に、

0x805387c <_IO_default_xsputn+108>:    call DWORD PTR [eax+0xc]

このcall文を実行したときに"hoge"が出力されました。もう一度詳しく追ってみると、改行文字を引数に渡した場合のみ、

0x8052b5e <_IO_new_file_overflow+190>: call 0x80523e0 <_IO_new_do_write>

という関数がルーチンの中に現れました。これはcallした先で6回ほど比較して条件に合えばジャンプということをやっており、最後の比較である

0x8052aef <_IO_new_file_overflow+79>: cmp esi,0xa
0x8052af2 <_IO_new_file_overflow+82>: je 0x8052b50 <_IO_new_file_overflow+176>

この部分で、0xaという改行文字のASCIIコードと比較して等しければ0x8052b50にジャンプするということをやっている為です。恐らく、それまでの文字列をどこかバッファのようなところに保存しておき、改行が現れたら一気に表示するということをやっているんだろうと考えました。これによって、先に述べた謎の現象も少し理解することができました。改行文字が現れていないため、"fuga"という文字列は表示されることがなくexitの後に表示されているのだと思います。

では、改行文字と比較するのではなく"hoge\nfuga"の内の"a"と比較すれば、“a"は最後にしか現れていないので"hoge\fuga"が一気に表示できるのではないかと考えました。よって、バイナリを書き換えてみます。objdumpで逆アセンブルした結果を見て、上の比較しているところの前後の機械語などを調べます。そして、vimのバイナリモードを使い、その機械語にマッチする部分を探します。”%!xxd"で16進ダンプすると、2735行目の末尾から2736行目の頭にかけて、見つけることができました。0xaaefから0xaaf1の3バイトです。0xaaf1の"0a"を"61"に書き換えます。"%!xxd -r"で保存し、再度gdbで見てみると、

0x8052aef <_IO_new_file_overflow+79>: cmp esi,0x61

となっており、書き換えることができました。そして、"hoge\nfuga"という文字列の内の"a"を渡したときにジャンプが成功し、"hoge\nfuga"を一気に表示することができました。では、表示している関数に対してさらにステップインしてみます。この先4、5回ステップインするので、遷移した先だけ書きます。

0x80523f8 <_IO_new_do_write+24>: call 0x8050cd0 <new_do_write>

0x8050cfc <new_do_write+44>:  call DWORD PTR [eax+0x3c]

0x8051b5b <_IO_new_file_write+91>:    call 0x806cfc0 <write>

0x806cfdc <__write_nocancel+18>:  call DWORD PTR ds:0x80ea9f

ここでようやく終わりを迎えました。最終的にどうやって文字列が出力されていたかというと、

0xf7ffcc85 <__kernel_vsyscall+5>:  sysenter

このように、sysenterという命令によってシステムコールが呼ばれていたことがわかりました。sysenterの下に、int 0x80というこれもまたシステムコールを呼ぶ命令ですが、これは実行されていませんでした。

gdbではwhereコマンドを使えば関数の呼び出し手順を調べることができます。

gdb-peda$ where
#0 0xf7ffcc89 in __kernel_vsyscall ()
#1 0x0806cfe2 in __write_nocancel ()
#2 0x08051b60 in _IO_new_file_write ()
#3 0x08050cff in new_do_write ()
#4 0x080523fd in _IO_new_do_write ()
#5 0x08052b63 in _IO_new_file_overflow ()
#6 0x0805387f in _IO_default_xsputn ()
#7 0x08052121 in _IO_new_file_xsputn ()
#8 0x0807e23f in vfprintf ()
#9 0x0804ebfa in printf ()
#10 0x0804889a in main ()
#11 0x08048ad1 in generic_start_main ()
#12 0x08048ccd in __libc_start_main ()
#13 0x08048757 in _start ()

printf()関数のまとめです。

printf()関数はvfprintf()や他の関数を実行していき、writeシステムコールも使いながら、最終的にsysenter等のシステムコールを使い、文字列を表示する関数です。

次はfork()についてです。 私はまず、fork()をほとんど使ったことがなかったので、manで調べました。NAMEが"fork - create a child process"となっている通り、子プロセスを作るものだとわかります。SYNOPSISに、

#include <unistd.h>

pid_t fork(void);

このように書かれており、"pid_t"という型で宣言されているのだとわかりました。"pid_t"は構造体でどこかに宣言されているのだろうかと思い、"unistd.h"ファイルに答えがあるのではないかと思い、"find / -type f |grep unistd.h"として、探しました。環境はprintf()の時と同じく、ubuntu16.04の64bitです。大量にヒットしましたが、本体が書いてありそうな

/usr/include/unistd.h

このファイルを見てみました。すると、263行目に書いてありました。

typedef __pid_t pid_t;

__pid_tをtypedefしたものであるのはわかったのですが、__pid_tの正体がわからないのでさらに調べます。"find ./ -type f |xargs grep ‘__pid_t’"を/usr/include内で実行して調べると、これまた大量にヒットしましたが、

./x86_64-linux-gnu/bits/types.h

に、それっぽいものを見つけました。

__STD_TYPE __PID_T_TYPE __pid_t; /* Type of process identifications. */

__STD_TYPEは上のほうで

# define __STD_TYPE __extension__ typedef

このようにマクロで定義されていました。__extension__を使う理由は、この定義の上に"C89でlonglongのような標準外の型を使うから"と書かれていました。__PID_T_TYPEを調べる必要があるので、さらに続けます。"find ./ -type f |xargs grep ‘PID_T_TYPE’"で調べると、

./x86_64-linux-gnu/bits/typesizes.h

このファイルがヒットしたので見てみると、

#define __PID_T_TYPE __S32_TYPE

これもマクロで定義されていたので、"__S32_TYPE"を調べます。'S32_TYPE’をgrepするようにしてみると、先ほど見たtypes.hがヒットしました。もう一度見てみると、

#define  __S32_TYPE int

ありました。結局"pid_t"という型はint型だとわかりました。あとはfork()の戻り値について知りたいので、またmanの結果を眺めます。RETURN VALUEに書いてありました。

どうやら、成功すると子プロセスのPIDが親プロセスに返されて、0が子プロセスに返されるみたいです。失敗すると-1が親プロセスに返されて、子プロセスは作られずに適切なerrornoがセットされるとあります。実際に下のようなコードを書いて実行してみました。

https://gist.github.com/ywkw1717/16332f539dc05d232657b4b2295245d4

pidの戻り値によって分岐するようにして、それぞれでpidを出力するようにしました。実行結果は

parent
23802
child
0

このようになりました。プロセスの複製に成功して子プロセスのPIDが親プロセスに返されてparentが表示され、その後0が子プロセスに返されて今度はchildが表示される、ということでしょうか。また、fork()と聞いたとき、forkbombが真っ先に思い浮かびました。

:(){:|:&};:

これがforkbombです。:という名前の、自分自身を2回起動してバックグラウンドで実行するという関数になっています。最後の:が実行を意味しています。実行すると、プロセスが無限に増殖するというDoS攻撃の一つです。仮想マシンで実行したとき

:: fork failed: resource temporarily unavailable

という表示が大量に出力され、ほとんどなにもできなくなってしまいました。どうすれば防げるのだろうと考えましたが、プログラムごとに生成できるプロセスに制限をかけてしまえば問題ないのかなと思いました。

fork()が実行される過程をgdbで追いかけようと思ったのですが、printf()に文字数を使いすぎてしまったので、これで終わりにします。

選択問題A-5

個人的にこの問題が一番難しかったです。試行錯誤しましたがエクスプロイトが書けておらず、問題の完答ができていないので、できたところまでを書きます。

まずは問題文にある通り、カーネルのバージョンを3.8~4.4のものを用意することから始めました。手元のUbuntu16.04は4.8.0-52-genericだったので、もっと古いのを用意する必要がありました。Ubuntuで探してみたところ、15.10であるWily Werewolfのカーネルのバージョンが4.2だったので、これで試してみました。 与えられたソースコードでまず気になったのは、keyutils.hというヘッダファイルです。私は、テキストエディタは普段Vimを使っていて、与えられたソースコードを保存してみると、keyutils.hの行がシンタックスチェックに引っかかりました。そこでkeyutils.hを探すことから始めました。Ubuntuでは”apt-cache search ファイル名”で、そのファイルがどのパッケージに入っているのか調べることができるので、探してみました。以下のようになりました。

keyutils - Linux Key Management Utilities
keyutils-dbg - Linux Key Management Utilities (debug)
libkeyutils-dev - Linux Key Management Utilities (development)
libkeyutils1 - Linux Key Management Utilities (library)

下の2行のどちらかだと思うので、libkeyutils1をインストールしてみました。ですが既に最新バージョンだと言われてしまったので、あとはlibkeyutils-devしかないだろうと思いインストールしてみたところ、シンタックスチェックは通りました。ですがgccコンパイルしてみたところ、`keyctl’ に対する定義されていない参照です、とエラーを吐かれました。lオプションで必要なライブラリをリンクする必要があると思い、-lkeyutilsを付けて実行してみたところコンパイルできました。

次に、ソースコードを読んでみました。最初にint型でカウンタ変数iを、key_serial_t型でserialを、それぞれ宣言しています。key_serial_tは、/usr/include/keyutils.hを見たところ、int32_tのtypedefになっていました。int32_tは4バイトです。その後、serialにkeyctl(KEYCTL_JOIN_SESSION_KEYRING, “leaked-keyring”)を代入しています。keyctlがわからなかったので、そこから調べました。検索するとIBMのサイト( https://www.ibm.com/developerworks/jp/linux/library/l-key-retention.html )がTopに出てきて、Linuxの鍵保存サービスについて詳細に解説されていたので、まずはLinuxのkeyringという機構について理解することから始めました。暗号化方式、認証トークン、ドメイン間のユーザー・マッピングなどのセキュリティー関連項目などの認証データをLinuxカーネルにキャッシュすることで、様々な利便性の向上を図ろうというものです。

この問題の脆弱性はCVE-2016-0728として登録されており、当時大きな問題になっていたことがわかりました。keyctlにKEYCTL_JOIN_SESSION_KEYRINGを渡すと、セッション鍵リングを新しいセッション鍵リングで置き換える関数である、join_session_keyringを呼ぶことができます。join_session_keyringは、process_keys.cに書かれており、脆弱性はこの関数に存在します。カーネルのバージョンが4.4以降なら修正されているはずなので、4.5とのdiffを取ってみました。すると、797行目の

key_put(keyring);

という行だけが違いました。つまり、この行を追加するだけで脆弱性に対応できているということです。key_putは鍵を開放する関数なので、鍵の解放を怠っていたせいで脆弱性が生まれてしまったということでした。このkey_put(keyring)が追加されている部分は

else if (keyring == new->session_keyring) {
key_put(keyring);
ret = 0;
goto error2;
}

このようになっています。keyringにはfind_keyring_by_name(name, false)の結果が代入されています。find_keyring_by_nameはkeyring.cというファイルに定義されており、nameに該当するものがあればそのkeyringへのポインタを返し、無ければ-ENOKEYを返します。一方、newにはprepare_creds()の結果が代入されています。prepare_credsは、”修正するために新たなcredentialsのセットを準備する”役割を担っており、commit_creds()と対で使用されています。処理の中には、key_get(new->session_keyring)という部分があります。key_getは真ならば__key_get(key)を返し、偽ならばkeyを返します。__key_get(key)では、渡されたkeyのusageをインクリメントするように

atomic_int(&key->usage);
return key;

という処理になっており、ここでusageの値がインクリメントされていることがわかります。この処理に行き着く条件というのはkeyringとnew->session_keyringが等しい時です。それは、プロセスが現在のセッション鍵リングを全く同じものに置き換えようとする時を指します。つまり、脆弱性の修正のために追加されたkey_put(keyring)が存在しなかった場合、キーリングオブジェクトへの参照が残っているので、usageの値がoverflowしてしまう危険性があります。実際に構造体keyが定義されているkey.hを見てみると

struct key {
atomic_t usage; /* number of references */

このように、atomic_t型で宣言されています。atomic_tはtype.hで宣言されていて、int型の要素を一つ持つだけなので、atomic_tはint型と同じサイズであると言えるはずです。この脆弱性を悪用するためには、ここ( http://perception-point.io/2016/01/14/analysis-and-exploitation-of-a-linux-kernel-vulnerability-cve-2016-0728/ )で解説されている通りに、まずusageを0x100000000まで増加させ、0にする必要があります。

この脆弱性を突いてroot権限を奪うPoCが公開されていたので、まずはそれを動かしてみて挙動を把握し、それから自分でプログラムを書いてみるという順序で進めることにしました。検証した環境は、冒頭で記述したUbuntu15.10です。実行してみたところ、いつまで経っても終わる気配がありませんでした。Intel Core i5-7200U のノートPC上でVirtualboxを起動させ、そこで実行させていました。どうしようかと思いましたが、プログラムの処理を並列化することで高速化できないかと考えた結果、openmpというものを使うことにしました。プログラムに数行加えるだけだったので、とても簡単に実現することができました。本来であれば家に余っている残り2台のノートPCを合わせて、グリッドコンピューティング的なことができないかと考えていたのですが、少し設定をやってみて詰まるところがあり、ここで時間を取られるのも嫌だなと思ったのでデスクトップの自作PCを使うことにしました。こちらはコア数が4つであり、高速化が期待できそうだったので再びUbuntu15.10で環境構築を行い、実行してみました。すると、20分ほどでシェルを起動するところまではいったのですが、root権限が取れていませんでした。並列化はうまくいっているようでしたが、肝心のroot権限が奪えていなかったので、もう少し調べてみました。ここ( http://cyseclabs.com/blog/cve-2016-0728-poc-not-working )で解説されている内容を読んでみると、セキュリティ機構であるSMEP/SMAPが原因か、prepare_kernel_cred()とcommit_creds()のアドレスが正しくないことが原因だとありました。私はPoCの先頭で定義されているCOMMIT_CREDS_ADDRとPREPARE_KERNEL_CREDS_ADDRを見落としており、自分の環境に合わせて定義する必要がありました。これらの情報は/proc/kallsymsで見ることができますが、KADRというセキュリティ機構が有効になっていると、アドレスが全て0で隠されてしまいます。これを

$ sudo sysctl -w kernel.kptr_restrict=0

で無効化します。そして/proc/kallsymsを見ると、しっかりとアドレスが表示されており、その情報をプログラムに記載しました。ここら辺は、ももいろテクノロジーさんの記事( http://inaz2.hatenablog.com/entry/2015/03/21/175433 )を参考にしました。これで権限昇格を行えるかなと思って実行してみましたが、またしてもrootは取れていませんでした。そこで、もう一つの原因だと考えられるSMEP/SMAPについて調べてみました。るくすさんのスライド( http://sssslide.com/speakerdeck.com/rkx1209/kanerukong-jian-karafalsesekiyuritei )で紹介されていたので参考にしました。SMEP(Supervisor Mode Execution Prevention)が、カーネルモードの時はユーザ空間のコードを実行できないようにするもので、SMAP(Supervisor Mode Access Prevention)が、カーネルモードの時はユーザ空間のデータにアクセスできないようにするものだったので、今回はSMEPが原因だと考えました。そこで、SMEPを無効化できれば攻撃は成功するのではないかと思い、まずは自分の環境でSMEPが有効なのかどうかを調べてみることにしました。

/proc/cpuinfoのflagsにsmepが現れていればsmepが有効だと、上述のももいろテクノロジーさんの記事にあったので、smepをgrepしてみました。ですが、有効ではありませんでした。当然有効化されているものだと思っていたので、どういうことかわからず、Windows側でも調べてみました。Coreinfo.exeというものを使って調べてみたところ、こちらでは有効になっていました。ホストOSでは有効化されているが仮想マシン上だと無効化されているのか、そもそも/proc/cpuinfoの情報が誤りで実は有効化されているのか、2つのパターンが考えられました。そこで、別の環境でも調べてみようと思い、Ubuntu14.04 LTSを用意しました。ですが、問題で与えられたプログラムを実行することで/proc/keysにleaked-keyringという名前で100個の参照をもつキーリングオブジェクトの情報が表示されるはずが、そもそも/proc/keysが存在しないと言われてしまいました。/proc/key-usersなどは存在するのに、なぜだろうと思い調べたところ、Ubuntuのバグだという情報( https://bugs.launchpad.net/ubuntu/+source/linux/+bug/1344405 )がありました。/boot/config-3.13.0-24-genericを見てみると、確かにCONFIG_KEYS_DEBUG_PROC_KEYが有効になっていませんでした。カーネルをリビルトする必要があるなと思い、この問題を修正すると同時に、SMEPも明示的に無効化できれば何か進捗があるのではないかと思い、ここ( https://forums.ubuntulinux.jp/viewtopic.php?id=17851 )を参考に進めました。/boot/config-3.13.0-24-genericにおいて、

CONFIG_KEYS_DEBUG_PROC_KEY=y
CONFIG_SECURITY_CAPABILITIES=y

をそれぞれ追加してから、念のためCONFIG_X86_SMAP=yを削除。そして、

$ sudo apt-get install kernel-package libncurses5-dev

で必要なパッケージをインストールして

$ sudo apt-get install linux-source-3.13.0

カーネルのソースをインストールします。次に、ビルドする段階でSMEPを無効化します。念のためSMAPも無効化しました。linux-source-3.13.0/arch/x86/kernel/cpu/common.cの883行目のsetup_smepと884行目のsetup_smapをそれぞれコメントアウトlinux-source-3.13.0/arch/x86/kernel/cpu/common.cの270行目と271行目もコメントアウト

// if (cpu_has(c, X86_FEATURE_SMEP))
// set_in_cr4(X86_CR4_SMEP)
$ sudo cp /boot/config-3.13.0-63-generic .config
$ sudo make oldconfig

.configファイルを作ってから、makeします。ここでいろいろ聞かれますがsmapについて聞かれたとき以外は全てEnterしました。次は

$ sudo make menuconfig

でパラメータを設定します。Security optionsで、"Enable the /proc/keys file by which keys may be viewed"にYを入力して*にします。最後に

$ sudo make-kpkg clean
$ sudo make-kpkg --initrd kernel_image

カーネルのリビルドを行います。

2時間くらいかかりましたが無事終わったので、.debをインストールして再起動。

$ sudo dpkg -i linux-image-3.13.11-ckt39_3.13.11-ckt39-10.00.Custom_i386.deb
$ sudo reboot

uname -rでカーネルのバージョンを調べてみると、3.13.11-ckt39になっていました。肝心の/proc/keysですが、ちゃんと存在していました。しかし、与えられたプログラムを実行してみても/proc/keysに情報が追加されないので、まさかパッチが当たったものではないかと思いprocess_keys.hを探してみると、/usr/src/linux-source-3.13.0/security/keys/process_keys.cにjoin_session_keyring関数があり、脆弱性が修正された印であるkey_put(keyring)が存在していました。その行をコメントアウトしてから、再びカーネルビルドします。無事終了したので、PoCを動かしてみると

keyctl: Disk quota exceeded

という文章が大量に出力され、クラッシュしてしまいました。再起動してもう1度実行してみるとシェルを起動するところまでいきましたが、肝心のroot権限が取れていません。まだSMEPを無効化する手段はあったのでそちらも試します。/usr/src/linux-source-3.13.0/Documentation/kernel-arameters.txtの2043行目にnosmepがあるので、これを追加してみました。起動する時のUbuntuのロゴが出てくる段階でShiftキーを押します。eで編集できるので、linuxという行を選択してnosmep=1を追加。再び実行してみるも、keyctl: Disk quota exceededでクラッシュしました。指定の仕方が間違っているのかなと、nosmepという風に変えて追加しましたが、これでもrootになることはできませんでした。

他にわかったことが1つあり、Ubuntu15.10で作業していた時のことです。PoCの実行を開始するとフル稼働するため画面が固まって動かなくなるので、ネットワークの設定を変えて、仮想マシンにプライベートIPアドレスを割り振ってsshで接続できるようにしました。終了するのを待っていたのですが、CPU使用率的に実行が完了しているはずなのに、画面が固まってしまったときがありました。sshしてからtopコマンドで見てみると、systemd-journalctlというプロセスがCPU使用率100%になっていました。journalctlは、/var/logにjournalというディレクトリを作っておくことで、ログデータが消えずに保存することができるので、それで確認してみたところ以下のようなログがあり、

May 27 01:10:43 yyy-VirtualBox kernel: BUG: unable to handle kernel NULL pointer dereference at 0000000000000009
May 27 01:10:43 yyy-VirtualBox kernel: IP: [<ffffffff811de366>] kmem_cache_alloc+0x76/0x200
May 27 01:10:43 yyy-VirtualBox kernel: PGD d6a0b067 PUD d4574067 PMD 0
May 27 01:10:43 yyy-VirtualBox kernel: Oops: 0000 [#1] SMP

NULL pointer deferenceを確認することができました。

いろいろ試行錯誤しましたが、カーネルという巨大な存在が絡んでくると、一気に難易度が増すなと感じました。

動かそうとしていたPoC( https://gist.github.com/PerceptionPointTeam/18b1e86d1c0f8531ff8f )と、これに並列処理を加えたもの( https://gist.github.com/ywkw1717/124025091edeedf35a64f6c53980114a )を記載しておきます。( /proc/keysの値の変化を見たかったので、それをcatする処理も追加しています )

最後に、このような攻撃を緩和する対策手法について書きたいと思います。SMEPやSMAPやKADRは今回初めて知ることができ、調べていく過程でSELinuxなるものも知りました。攻撃を緩和するためには、SMEPのような防御機構は必須だと思います。ですが調べていく中で、SMEPはROP等のテクニックを使うことで回避が可能だとわかりました。ではどのような対策方法が考えられるのか、3つほど挙げてみました。 まずは、当たり前ですが一番有効かつ簡潔な対策として、アップデートを頻繁に行い常に最新版にしておくということが非常に重要だと思います。公式から提供されているパッチが当たったバージョンを使っていない場合、既知の脆弱性を攻撃者に突かれてしまう危険性があります。頻繁にアップデートしておくことはとても大事なことだと思います。 次に、この問題を解いていて気になったのは、攻撃段階で必要になった2つのアドレスです。これらはstaticなものだと書かれていましたが、ASLRのようにアドレスをランダマイズ化することはできないのでしょうか。ASLRは総当たりで破られてしまったり、攻撃を完全に防ぐ手段ではないですが、緩和することは可能です。 また、今回の脆弱性の検証に、公開されているPoCを使いましたが、このプログラム内では大量の計算を行っています。約42億回のループを回しており、明らかに不自然な動きをしているプログラムです。よって、そのような振る舞いを検知して強制終了させるような防御機構があれば、今回のような攻撃は防げるのかなと考えます。

解いてみた感想としては、難易度が高く、正直他の問題に変えようかと考えた時もありました。しかし、問題に興味を持って始めたのに難しいからやらないというのは理由にならないと思います。わからないぶん、多くのことを自分で調べて手を動かしてやってみたので、得たものは非常に大きかったです。

選択問題A-6

まず完成したコードを貼ります。 gistでsecretにして公開しました。

https://gist.github.com/ywkw1717/d1e5927306b64ea31f10c24aca0b937d

PEファイルはCTFでもよく出てきており、Ollydbgなどで解析したこともあったので、より理解を深めるためにもこの問題に取り組みました。最初はPythonで書こうと思いました。pefileというモジュールを見つけたのですが、これを使っては他者のコードは利用しないというルールに反するかなと思ったので、やめました。次に目を付けたのがC言語です。どうやらWinNt.hというヘッダファイルにPEファイルの構造体が含まれているらしいのでそれをincludeして使おうと思いました。ですが、これでは環境依存するプログラムになってしまうのではないかと考えました。なぜなら、WinNt.hはWindowsでしか使うことができないからです。そこで、Linuxなどでも使えるような環境依存しないプログラムを作ろうと考え、WinNT.hで定義されている構造体をそのまま自分のプログラムに直書きするようにしました。そして、ファイルの頭からパースしていき文字列型リソースを取り出すようにしました。

PEヘッダの構造に関してはこのサイト( http://home.a00.itscom.net/hatada/mcc/doc/pe.html )や持っていた本(デバッガによるx86プログラム解析入門)を参考にしました。より詳細なところはMicrosoftの資料( https://msdn.microsoft.com/en-us/library/ms809762.aspx )を参考にしました。

このプログラムはまず、コマンドライン引数から対象のファイル名を受け取ります。コマンドライン引数が渡されていないときやファイルが開けないときはエラーメッセージを出力して終了します。バイナリモードでファイルを開いた後は、getDosHeader関数にファイルポインタを渡してMZスタブを取得します。PEファイルは、MZスタブやMZ-DOS ヘッダと呼ばれるものから始まります。0x5A4Dがシグネチャとなっており、16進ダンプしたときに先頭に”MZ"という文字が現れるはずです。これはWinNT.hで構造体として宣言されています。WinNT.hのソースはここ( https://source.winehq.org/source/include/winnt.h )を参考にしました。型の宣言で使われているWORDはunsigned shortのtypedefで2バイトです。DWORDはここ( https://msdn.microsoft.com/en-us/library/windows/desktop/aa383751(v=vs.85).aspx )ではunsinged longとしてtypedefされていましたが、自分の環境(Ubuntu16.04 64bit)でsizeof(unsigned long)を出力してみたところ8バイトになってしまいました。これは環境による問題で、WindowsMac OS XUbuntu・Solaris11などで試してみましたが、統一されておらず、さらに32bitと64bitでも違う値になってしまいました。そこで”stdint.h"に入っている”uint32_t”を使うことで環境による差異を無くしました。uint32_tは4バイトです。MZスタブの中でも特に、NTヘッダへのオフセットを保持している”e_lfanew”というメンバが重要です。NTヘッダではPEファイルであることを示すシグネチャ”0x50450000(PE00)“、Fileヘッダ、Optionalヘッダをメンバとして保持しています。ですがこのプログラムでは、構造体を厳密に宣言する必要はないと考え、NTヘッダはPEファイルのシグネチャのみを保持するようにしました。getNtHeaderでNTヘッダを読み込んだ後は、Fileヘッダです。Machineというメンバは、32bitなら0x014C、64bitなら0x8664です。NumberOfSectionsはセクションの数を表すので、セクションヘッダを確保するときに使用します。Fileヘッダを読み込んだ後はOptionalヘッダを読み込みますが、Optionalヘッダは32bitの場合と64bitの場合で少し構造が違います。なので、FileヘッダのMachineで条件分岐して、32bitと64bitに対応するようにしました。Optionalヘッダの下にはセクションヘッダがあり、その下には各セクションの実態があります。先ほど述べた通り、セクションヘッダを確保するためにFileヘッダのNumberOfSectionsの数だけfor文を回し、各セクションの情報を取得します。飛ばし飛ばしでしたが、これでPEヘッダの取得が完了しました。ここまでは特に詰まることなく実装できたのですが、この後からが難しかったです。

まず、問題文にある「文字列型リソース」が何を指しているのかがわからず、stringsコマンドを打った時に表示されるような、表示可能文字列全てを取得するのか.rsrcセクションにある文字列を取得すればいいのか、よくわかりませんでした。そこで、実行したときの動作を考察したり、ILSpyという.NETアプリケーションをデコンパイルできるツールを使い、与えられたプログラムのソースコードを見てみました。与えられた.NETアプリケーションは”Hello world!”、”hoge fuga”、”string test”という3つの文字列を表示して終了しています。さらに、ILSpyでデコンパイルした結果の中にあった、Resourcesというフォルダの中の”ConsoleApplication1.Properties.Resources.resources”というファイルにString Tableがあり、”String1”、”String2”、”String3”をName、”Hello world!”、”hoge fuga”、”string test”をValueとして表していました。よって、”わざわざ3つも文字列を出力していること”、”String Tableが3つのペアを保持していること”から、問題文にある文字列型リソースとはこのString Tableで管理している3つの文字列のことを指しているのではないかと推測しました。

また、.textセクションの構造が全くわかりませんでした。今まで見てきたPEファイルの構造を説明しているサイトや本では、プログラムコードが格納されていること以外はわからず、どうやってパースしようか詰まってしまいました。ですが、ここであることを思い出しました。このプログラムはただのPEファイルではなく.NETアプリケーションであることです。そのことに注意しながらさらに調べると.NETアプリケーションでは.textセクションに格納される内容が違うことがわかりました。stackoverflowに投稿されていたこの質問( http://stackoverflow.com/questions/25095894/understanding-text-section-of-pe-executable )の解答が参考になりました。.NETでは.textセクションに格納される内容が、普通のPEファイルで格納されるものとは違う、.NET特有のものが格納されるのだとわかりました。CLRヘッダというものが使われることがわかり、CorHdr.hファイルで定義されているらしいので、そこに書かれていた”IMAGE_COR20_HEADER”をプログラムの中で定義して、CLRヘッダを取得してみました。最初は.textセクションの開始数バイトが何を指しているのかわからず、ConsoleApplication1.exeのバイナリを見てみて、CLRヘッダの場所を決め打ちで指定していましたが、CLRヘッダへのオフセットを求めてそれを使うようにしました。CLRヘッダへのオフセットもどこかに格納されているはずだと思い調べたところ、Optionalヘッダの中の”DataDirectory”の15番目にそれらしきものが格納されていることがわかりました。DataDirectoryは”IMAGE_DATA_DIRECTORY”という構造体で宣言されています。MicrosoSoftの資料( https://msdn.microsoft.com/en-us/library/windows/desktop/ms680305(v=vs.85).aspx )を参考にしました。ですがこの中に格納されていたのは仮想アドレスだったので、CLRヘッダのVirtualAddressから.textセクションのVirtualAddressを引くことで、.textセクションからCLRヘッダへのオフセットを求めることができました。CLRヘッダを読み込んだ後の0x1330から始まる機械語がなにを指しているのかわかりませんでしたが、このサイト( http://www.atmarkit.co.jp/fdotnet/technology/idnfw11_05/idnfw11_05_03.html )の後半で解説されていました。どうやらメイン関数やその他の関数、つまりコード部分が格納されていて、.NETではILという中間言語が使われていることがわかりました。ILSpyでも平気ですが、使ったことがなかったので、上のサイトで使われていたildasm.exeを使ってConsoleApplication1.exeのメイン部分を読んでみました。メイン関数では”get_String1”、”get_String2”、”get_String3”という3つの関数と、”WriteLine”というコンソールへの出力を担っていそうな関数で構成されていました。ildasm.exeの結果とConsoleApplication1.exeのバイナリを対応させながら読んでいき、callが0x28、retが0x2a、stloc.0~stloc.2がそれぞれ0xa,0xb,0xc、ldloc.0~ldloc.2がそれぞれ0x6,0x7,0x8など、命令と機械語の対応がわかったりしました。

ですが、コード部分が終わったあとが何を指しているのかがまたわからなくなりました。そこで今度は、他の.NETアプリケーションとConsoleApplication1.exeの構造を比較してみることにしました。サンプルとなるものが、過去に参加したCTFで得た.NETアプリケーション2つしかありませんでしたが、それらと比較してみました。すると、”BSJB”という文字列が3つのプログラム全てに現れていました。さらに、ConsoleApplication1.exeの”Hello world!”などの文字列はこのBSJBのすぐ前に現れていること、他の2つのプログラムはBSJBの前に特にそれらしき文字列が現れていないことがわかりました。”BSJBは何かのシグネチャであること”、”ConsoleApplication1.exeは文字列を表現するのにString Tableのようなリソースを使う方式を使っていること”について考察しました。バイトオーダはリトルエンディアンかなと思い、BJSBとし、”0x424A5342”という文字列で検索してみると、このサイト( http://www.ntcore.com/files/dotnetformat.htm )で情報を見つけることができ、Metadataセクションのシグネチャであることがわかりました。知りたいのはMetadataセクションとILコードの間に格納されているデータなので詳しくは調べませんでしたが、.NETアプリケーションには必ず格納されているものだとわかりました。

ここからが一番詰まったのではないかと個人的には思っています。MetadataセクションとILコードの間に格納されているデータが、一体どのような構造で格納されているのかがさっぱりでした。ふと、バイナリに現れている”System.Resources.ResourceReader”という文字列が気になり調べていたらようやく進みました。MicrosoSoftの資料( https://msdn.microsoft.com/ja-jp/library/system.resources.resourcemanager.magicnumber(v=vs.110).aspx )に、ResourceManagerのMagicNumberフィールドは値が”0xBEEFCACE”に設定されると書いてあります。ConsoleApplication1.exeにも0x32Cからリトルエンディアンで格納されていました。ResourceManagerと、それより下の構造に関してはResourceSet.csに定義されている構造体( https://referencesource.microsoft.com/#mscorlib/system/resources/runtimeresourceset.cs,95 )と、実際のバイナリを見ながら実装しました。CLRヘッダの下のILコードは読み込む必要がなかったので、CLRヘッダの時と同様にオフセットを求めて、Resource Manager Headerにアクセスするようにしました。Resource Manager HeaderのメンバであるMagicNumberが始まる前の数バイトは、数えてみるとどうやらRuntime Resource Reader Data Sectionまでのサイズを表していることがわかったので、Resource Manager Headerに格納するようにしました。Runtime Resource Reader Name Sectionの、UTF-16で格納されている文字の前にある1バイトは文字数の長さを指していたので、これもメンバとして保持するようにしました。また、それぞれの文字列の長さの前の1バイトが常に0x1となっていて、これは文字列の開始を表すのかなと思いましたが、確証がなかったのでTopというメンバで保持するようにしました。

ここまで実装することで、ようやくConsoleApplication1.exeの3つの文字列型リソースを取り出すことができました。もしResource Managerなどを使わない形式であれば、Resource Headerが見つからないということで終了するようにしました。他にも、各ヘッダのシグネチャが不正だった場合などは、即終了するようにしました。

実行結果を貼ります。各セクションの情報を表示してから、最後に文字列型リソースを表示するようにしました。1万文字の制限を超えてしまいそうなので、少し省略しています。

$ ./string_parser ConsoleApplication1.exe
File size : 6656

----DOS HEADER----
e_magic: 5A4D
e_clip: 90
…
e_lfanew: 80

----NT HEADER----
Signature: 4550

----FILE HEADER----
Machine: 14C
…

----OPTIONAL HEADER----
Magic: 10B
…
LoaderFlags: 00
NumberOfRvaAndSizes: 10

----IMAGE_DATA_DIRECTORY----
VirtualAddress: 00
Size: 00

VirtualAddress: 2E1C
Size: 4F
…
VirtualAddress: 2008
Size: 48

VirtualAddress: 00
Size: 00

----SECTION HEADER----
[1]
Name: .text
PhysicalAddress: E74
VirtualSize: E74
VirtualAddress: 2000[2]
Name: .rsrc
PhysicalAddress: 590
VirtualSize: 590
…

----CLR HEADER----
cb: 48
MajorRuntimeVersion: 02
MinorRuntimeVersion: 05
…

----Resource Manager Header----
Magic: BEEFCACE
HeaderVersion: 01
…

----Runtime Resource Reader Header----
Version: 02
NumberOfResources: 03
NumberOfType: 00
Padding: PADPADP

HashValues: 82821361
VirtualOffset: 00

HashValues: 82821362
VirtualOffset: 13
…

----String Resource----
[1]
Name: String1
Value: Hello world!

[2]
Name: String2
Value: hoge fuga

[3]
Name: String3
Value: string test

これで終わりかと思いましたが、テストケースなどが手元にないので文字列型リソースが取得できない場合もあるのではないかと思い、他のプログラムもパースしてみることにしました。対象にしたのは”mscorlib.dll”です。これは.NETフレームワークの基本的なクラスやライブラリを格納しているファイルです。よって、このファイルの文字列型リソースを取得することができれば、大抵のファイルで成功するのではないかと考えました。

早速mscorlib.dllを対象に実行してみると、案の定取得できていませんでした。Runtime Resrouce Reader Name Sectionの処理の途中で取得したものが無茶苦茶になっており、どうやら原因は取得しようとしたもののサイズでした。ある一定以上のサイズの時は、サイズを格納する1バイトの後に、もう1バイト”なにか”が格納されていました。とりあえずこれを適当にスキップして回避してみましたが、Runtime Resource Reader Data Sectionのほうも同様でした。ある一定以上のサイズの時は、サイズのあとの1バイトに”なにか”が格納されています。その”なにか”は、0x3である時と0x1である時が見つかりました。0x1の時は、サイズを格納している1バイトの数でパースすることで成功するのですが、0x3の時は明らかにサイズが足りませんでした。サイズの1バイトと、”なにか”を合計した2バイトをリトルエンディアンとして計算してみてもサイズが大きすぎます。全くわからなかったので、その現象が起きるところをいくつか集め、考察してみました。すると0x3の場合は、実際の文字列の長さからサイズの1バイトを引いた値が、どれも0x100になりました。”なにか”が0x2になっている箇所はないかともう一度探してみたところ、ありました。0x2の時は、実際の文字列の長さからサイズの1バイトを引いた値が0x80になりました。0x1の時は、実際の文字列の長さとサイズの1バイトが変わらないことから、これは”0x80 * (n - 1)”をサイズの1バイトに足すことで、実際の文字列のサイズが取得できるのではないかと考えました。1バイトで表現できる数は0xffで255文字です。だからと言って、0xffを超えた時だけサイズをもう1バイト増やしていたのでは、サイズとして1バイト読み込んだ時に、その次の1バイトがサイズの情報なのか、実際の文字列の情報なのかを判別する術がありません。”なにか”が0x1の時に、サイズの1バイトと実際の文字列のサイズが変わらなかったのは、0x80以上の時は2バイト確保するようにしており、0x80~0xffの時は0x1、もし0xffを超えることがあれば0x1が0x2になる、0x3も同様です。このようにすることで、サイズとして1バイト読み込んだ時に、次の1バイトがサイズなのか文字列なのかがわかるようになっているのだと思います。よって、サイズとして確保した1バイトが0x80以上の時は、もう1バイト読み込んで実際のサイズを求めるようにしました。これでようやく、全ての文字列型リソースをパースすることができました。mscorlib.dllの文字列型リソースは、3162個ありました。

実行結果が多すぎるので、文字列型リソースの部分の結果だけを貼ります。

$ ./string_parser mscorlib.dll

…

----String Resource----
[1]
Name: Acc_CreateAbst
Value: Cannot create an abstract class.

[2]
Name: Acc_CreateAbstEx
Value: Cannot create an instance of {0} because it is an abstract class.

[3]
Name: Acc_CreateArgIterator
Value: Cannot dynamically create an instance of ArgIterator.

…

[3159]
Name: event_TaskScheduled
Value: Task {2} scheduled to TaskScheduler {0}.

[3160]
Name: event_TaskStarted
Value: Task {2} executing.

[3161]
Name: event_TaskWaitBegin
Value: Beginning wait ({3}) on Task {2}.

[3162]
Name: event_TaskWaitEnd
Value: Ending wait on Task {2}.

Ubuntu16.04 64bit、Mac OS X 10.9 64bit、Solaris11 32bit、Windows10 64bitなどで動作することを確認しました。Windowsで動かそうと思いexeファイルを生成しようとしたのですが、Visual Studio 2010ではエラーが出ることなくビルドできましたが、Visual Studio 2017では fopenではなく、fopen_sを使えということでエラーを吐かれました。fopen_sを使うようにすればビルドも成功し、正常な動作を確認することができたので、Visual Studio 2017でビルドする場合はfopen_sを使う必要がありそうです。

実装するのに1週間ほどかかってしまいましたが、PEファイルや.NETアプリケーションの構造の理解がかなり深まり、とても勉強になりました。