Cでhello, world(詳細解説)
切腹倶楽部のCでhello, worldをようやくしっかりと読んだ。
ソースコード
無断てんのりをば。怒られたら消す。
print(s,d) void*s,*d; {
__asm__(".quad 0xcfff48aef2fcffb1" : "=D"(d) : "D"(s), "a"(0));
__asm__("syscall" :: "a"(1), "D"(1), "S"(s), "d"(d-s));
}
main() {
print("hello, world\n");
return 0;
}
解説
(原文ママ)
mov $0xff,%cl
cld
repne scasb
dec %rdi
とっかかり
このコードを見たとき、最初は何もわからなかった。解説が何をしているのかもわからなかった。そもそもC言語で:
が使われている記法なんて知らないので本当に何もわからなかった。
更に言えばK&R的な昔懐かしいような関数定義も、軽く存在を知っていただけで書いたことはなく、どうして引数にd
があるにも関わらず引数は文字列のみなのか?という疑問から始まった。
詳細な解説
現代語に書き下す
void print(void* s){
void* d;
__asm__(".quad 0xcfff48aef2fcffb1" : "=D"(d) : "D"(s), "a"(0));
__asm__("syscall" :: "a"(1), "D"(1), "S"(s), "d"(d-s));
}
int main() {
print("hello, world\n");
return 0;
}
K&Rの表記では引数は必須ではないらしく、原文では単なるd
の初期化のためだけに引数として書かれているらしい(?)
後述するとこのd
は文字列の終端文字のアドレスを指しているのが、引数で渡したとしても結局上書きされてしまうので引数として定義する意味はよくわからなかった。
void*
ポインタ
私も知ったのは最近のことだが、void
型のポインタというのは任意の型を取りうるポインタである。
malloc
等の返り型にもなっており、実質的にCのany
である。
GCCインラインアセンブリ記法
世の常だが記号というのはGooglabilityが非常に低い。どうにかたどり着いたのが「GCC インラインアセンブリ」という構文であった。
上記のサイトではasm volatile()
という記法が紹介されており、__asm__
についても言及されている。(残念ながら上記サイトの参考文献はリンク切れしているのだが)
この記法について要約すると以下の通りになる。
void* output;
void* input1, input2;
__asm__(
"hoge" // ここにアセンブリを書く
: "=r"(output) // 最終的な出力となるレジスタ
: "r"(input1), "r"(input2) // 入力として使うレジスタ
)
:
ではレジスタとCの変数を結びつけ(Vueのv-bind
的な)ている。正確には、コンパイラに使用するレジスタを教えてあげることで安全に利用できるようにするらしい。
指定形式は"レジスタの種類"(C変数)
で行い、レジスタの種類は以下に規定されている。この種類についての情報が日本語ではほぼ存在しておらず苦しかった。
GCCインラインアセンブラの使い方 - bamboo’s blog
こちらのサイトでは日本語で説明を行っているが、この一覧は完全なものではない。現に山Dが利用しているD
については書かれていない。
完全な一覧はこちらにかかれている。以下にその表を転載する。(怒られポイント+1)
記号 | レジスタ |
---|---|
a | RAX, EAX, AX, AL |
b | RBX, EBX, BX, BL |
c | RCX, ECX, CX, CL |
d | RDX, EDX, DX, DL |
S | RSI, ESI, SI |
D | RDI,EDI, DI |
更に、私はアセンブリ初心者なのでRAX
,EAX
,AX
, AL
の違いがわからなかったがこれらはサイズの違いだけで同じ用途として利用されていることがわかった。(Wikipedia参照)
RAX
は64bit、EAX
は32bit、AX
は16bit、AL
は8bitであるらしい。
こちらのサイトにかかれている通り、asm
と__asm__
は基本的には同一である。しかしANCIなC言語で書いている場合asm
は予約後になっているらしく、__asm__
を使用する必要がある。
quadについて
インライン記法については理解できたものの、相変わらずquad
はわからない。これは64bitの数値を定義するものなので、最初はなにかの外部ライブラリのメモリアドレスを定義しているのだと思った。
しかしこのアドレスで検索しても出てくるのは切腹倶楽部のみという、非常に絶望的な状態であったため何かアドレスではない独自の意味合いなのではないかと考え始めた。
山D先生に質問してみるものの、返答は「ちゃんと解説書いてるよ(鼻ほじ)」「いつからqword幅の16進数がアドレスだと勘違いしていた!」という感動的なスパルタ方式だったため諦めて調査を続行。
以前にMWS CupというCTFに参加するために勉強したGhidraでコンパイルされたバイナリを解析することで何かヒントが得られないかと、何気なく検索をかけたところビンゴ!
quad
で定義されているのはリトルエンディアンで逆向きになったマシン語そのものだったらしい。かなりご丁寧にデコンパイルしたCコードまで付けてくれている。
というものの、デコンパイルされたCコードには見たことのない変数が多く定義されておりかなり意味不明なので、アセンブリから地道に読んでいくことにした。
同時に、意味が完全に不明だった解説もようやく理解した。この難読化されたquad
に書かれているアセンブリのことだったのだ。
1つめの__asm__
の意味
改めて山D先生のコードを掲載する。
__asm__(".quad 0xcfff48aef2fcffb1" : "=D"(d) : "D"(s), "a"(0));
ここまでの調べでquad
にかかれている実態は以下のアセンブリであることがわかった。また、実行結果がRDI
レジスタに格納され、それをvoid* d
が受け取ることもわかった。
そしてこのコードは何かしらの関数であり、引数?としてRDI
に文字列のアドレス(void* s
)を渡しておりRAX=0
で初期化していることもわかった。
更に、Ghidraのデコンパイルをなんとなく眺める限りでは、\0
を探しているようなことが書かれている。
ここまで判明するのに2時間ほどかかった気がする。で、そのアセンブリの実態は以下である。
b1 ff mov cl,0xff
fc cld
f2 ae repnz scas al,BYTE PTR es:[rdi]
48 ff cf dec rdi
ということで、これを擬似コードに書き起こしてみる。
// 以下はインラインアセンブリによる初期化
rdi=s // "D"(s)
al=0 // "a"(0)
ecx=256 // mov cl, 0xff
df=0 // cld
// repnz scas al, BYTE PTR es:[rdi]
while (ecx != 0){
zf = (al == *(char *)rdi);
rdi++ // dfが0なので左から右に読んでいく
ecx--
if (zfF) break;
}
rdi-- // dec rdi
実のところ、f2 ae
の部分の理解が怪しい。
while
とecx
のデクリメント、zf
に応じたループの終了を担当しているのがrepne
で、
scacb
がal
との比較やrdiのインクリメント、zf
の変更を行っているという認識である。
このあたりが最もよくわからず、理解に時間がかかった。最終的に先輩が以下の資料を見つけてくれたので解決できた。
x86 - REPNZ SCAS Assembly Instruction Specifics - Stack Overflow
要約すると、al
に入っている文字コードが見つかるまでrdi
をインクリメントさせている。
今回の場合は"a"(0)
により0が入っているので、ヌル文字を見つけていることになる。
この時点でd
にはヌル文字のあるアドレスが格納されている。この後、文字列の末尾を示すd
は出力対象の文字列の長さを取得するために利用されるのだが、長さにヌル文字は不要である。
したがって、ヌル文字分を除外し、正しい文字列長へ調整を行うのが最後のdec rdi
である。
余談1 dec rdi
を削除してみる
この検証を確認するために、dec rdi
を削除して出力の実験を行った。
\0
まで出力されていることがわかる。
おそらく
あと,NULLまでwriteしてたのも修正した。
と言っているのはこれ。
2つめの__asm__
の意味
こちらのアセンブリ文はかなりすぐに解決できた。
__asm__("syscall" :: "a"(1), "D"(1), "S"(s), "d"(d-s));
(Googlabilityの高い)syscall
という明確なキーワードが非常に助かる。
アセンブリでシステムコールを呼び出す方法はかなりすぐに見つけることができる。
Syscall Number for x86-64 linux (A)
rax
にシステムコール番号、その後引数を順番にrdi
,rsi
,rdx
に設定していく。
Linuxカーネルに見る、システムコール番号と引数、システムコール・ラッパーとは:main()関数の前には何があるのか(7)(1/2 ページ) - @IT
今回はターミナルに出力を行うので、rax
にwrite
のシステムコール番号1
を指定("a"(a)
)。
続いてrdi
ではファイルディスクリプタを指定。"D"(1)
であるため、/dev/fd/1
すなわち標準出力に対応している。
rsi
にはバッファの指定を行う。今回の場合はvoid* s
のアドレスの指定を行う。
rdx
は出力する長さの指定を行う。s
とd
には文字列の先頭と末尾のアドレスが指定されているため、d-s
で文字列長を取得できる。
以上の引数の指定によってシステムコールを発行し出力を行える。
リトルエンディアンのありがたみ
Javaの仮想マシンはビッグエンディアンだとか、Armはどっちでもいけるだとか、色々話を聞きますが結局どういうときにどっちがどう嬉しいのかがわからない。
単にプロトコル(アーキテクチャ)の違いでしか無いんだろうか。
quad
で定義した数値が実行される理由
これ調べてもよくわからなくて山Dに聞きました。quad
は単に64bitの数値を埋め込めるという理解で正しいらしい。
C言語の関数内において数値を埋め込んでいるために実行権限のあるメモリ内に展開され、実行できるという仕組みらしい。
この挙動はどこで説明されているのかもよくわからない。時間があるときに調べようと思う。
終わりに
基本的な命令しか知らなかった上に、知識として知っているだけで実際に書いたり読んだりといったことをしたことがなかったため非常に勉強になった。
理解するのにトータルで5時間ほどかかったが、最初から最後まで付き合ってくれた分類器先輩には感謝しかない。
そしてこれを数年前に書いており、色々と質問に答えてくれた山Dにも感謝。私はまだまだ弱い……