はじめに
C言語のコマンドライン引数について質問された際, よくよく考えたら何も理解していない事に気付きました.
そこで, コマンドライン引数にまつわる内容を掘り下げてみました.
- 対象: ある程度C言語を理解している方
- 環境: linux x86_64
- gcc version 10.2.0
コマンドライン引数とは
コマンドラインから引数として文字列をmain関数に渡せる仕組み
int main(int argc, char *argv[]) {
// ^^^^^^^^^^^^^^^^^^^^^^
// ↑ これ ↑
}
argc
はコマンドライン引数の数, argv
はコマンドライン引数の文字列へのポインタ配列です.
例えば, 以下のようにプログラムを実行すると
$ ./a.out 123 abc
このようにプログラムに値が渡されます.
assert( argc == 3 );
assert( !strcmp(argv[0], "./a.out") );
assert( !strcmp(argv[1], "123") );
assert( !strcmp(argv[2], "abc") );
assert( argv[argc] == NULL );
※ argv[0]
にはプログラム実行時のパスが渡される
※ argv
の最後の要素の次(argv[argc]
)にはNULL
が入っている
そもそも引数とは
関数に渡される情報のこと.
呼び出す側に指定する引数を実引数, 受け取る側の変数を仮引数と呼びます.
void func(int a) {
// ^^^^^
// 仮引数
}
int main(void) {
func(42);
// ^^
// 実引数
}
ということはmain関数のargc
とargv
は仮引数となります.
では, どのようにmain関数にコマンドライン引数を渡しているのでしょうか.
main関数の呼び出し元
とりあえずobjdump
で逆アセンブルしてみましょう.
// argcを返すプログラム
int main(int argc, char *argv[]) { return argc; }
$ gcc test.c
$ objdump -d -M intel a.out | less
0000000000001020 <_start>:
1020: f3 0f 1e fa endbr64
1024: 31 ed xor ebp,ebp
1026: 49 89 d1 mov r9,rdx
1029: 5e pop rsi
102a: 48 89 e2 mov rdx,rsp
102d: 48 83 e4 f0 and rsp,0xfffffffffffffff0
1031: 50 push rax
1032: 54 push rsp
1033: 4c 8d 05 66 01 00 00 lea r8,[rip+0x166] # 11a0 <__libc_csu_fini>
103a: 48 8d 0d ef 00 00 00 lea rcx,[rip+0xef] # 1130 <__libc_csu_init>
1041: 48 8d 3d d1 00 00 00 lea rdi,[rip+0xd1] # 1119 <main>
1048: ff 15 92 2f 00 00 call QWORD PTR [rip+0x2f92] # 3fe0 <__libc_start_main@GLIBC_2.2.5>
104e: f4 hlt
104f: 90 nop
--- 省略 ---
0000000000001119 <main>:
1119: 55 push rbp
111a: 48 89 e5 mov rbp,rsp
111d: 89 7d fc mov DWORD PTR [rbp-0x4],edi
1120: 48 89 75 f0 mov QWORD PTR [rbp-0x10],rsi
1124: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1127: 5d pop rbp
1128: c3 ret
1129: 0f 1f 80 00 00 00 00 nop DWORD PTR [rax+0x0]
main
と検索して合致したセクションのみ抜粋しました.
この結果を見ると, _start
でrdi
にmain
のアドレスを入れ, __libc_start_main
を呼び,
hlt
(CPUの動作を停止)しているのがわかります. つまり, __libc_start_mainでmain関数を呼んでいるということですね.
__libc_start_mainの中身
__libc_start_mainの中では何を行っているのでしょうか.
1048: ff 15 92 2f 00 00 call QWORD PTR [rip+0x2f92] # 3fe0 <__libc_start_main@GLIBC_2.2.5>
先ほどのobjdump
の結果を再掲しました.
後半にご丁寧にアドレス計算した呼び出し先が記述されていますが, @GLIBC_2.2.5
となっています.
これはGLIBCから動的に呼び出しているという意味になります.
-static
オプションを付けて静的リンクすると__libc_start_main
セクションの処理も見ることができます.
長いのでここでは要点のみ掲載します.
1041: 48 8d 3d d1 00 00 00 lea rdi,[rip+0xd1] # 1119 <main>
1048: ff 15 92 2f 00 00 call QWORD PTR [rip+0x2f92] # 3fe0 <__libc_start_main@GLIBC_2.2.5>
_start
セクションではrdi
レジスタにmain関数のアドレスを格納して, __libc_start_main
を呼び出しています.
4022ec: 48 89 7c 24 18 mov QWORD PTR [rsp+0x18],rdi
--- 省略 ---
402843: 48 8b 15 46 ea 0a 00 mov rdx,QWORD PTR [rip+0xaea46] # 4b1290 <__environ>
40284a: 8b 7c 24 0c mov edi,DWORD PTR [rsp+0xc]
40284e: 48 8b 74 24 10 mov rsi,QWORD PTR [rsp+0x10]
402853: 48 8b 44 24 18 mov rax,QWORD PTR [rsp+0x18]
402858: ff d0 call rax
40285a: 89 c7 mov edi,eax
40285c: e8 2f 61 00 00 call 408990 <exit>
そして__libc_start_main
ではrsp+0x18
の位置にrdi
を格納し,
なんやかんや処理をした後, rax
にmain関数のアドレスを入れて, call
しています.
この時, edi
にはargc
, rsi
にはargv
そしてrdx
には__environ
が入っています.
最後にedi
にeax
を格納し, exit
をcallしています. eax
にはmain関数の戻り値が格納されているので, その戻り値を引数にしてexit関数を呼んでいるということです.
こんなイメージ
exit(main(argc, argv, envp));
余談
-
バイナリレベルのインターフェース
どのように関数に引数を渡すのか等をプラットフォームごとにある程度決めた仕様のことをABI(Application Binary Interface)と言います.
ABIの一部に関数の呼び出し規約があります. これに従うことで, どのレジスタがどの引数に対応しているか等がわかります. -
エントリーポイント
プログラムにはエントリーポイントから始まります. ELFファイルの場合は
readelf
でELFフォーマットを解析することができます.$ readelf -h a.out
ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: DYN (Shared object file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x1020 Start of program headers: 64 (bytes into file) Start of section headers: 14640 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 11 Size of section headers: 64 (bytes) Number of section headers: 27 Section header string table index: 26
Entry point address
という欄に0x1020
と書かれています.
これは先ほどの_start
セクションのアドレスですね.
まとめると, 以下のような順番で処理が呼び出されているということがわかります_start => __libc_start_main => main
main関数の定義
そもそもmain関数の仮引数の個数や型は決められているのでしょうか?
Microsoft Cには以下の定義ができると記載されています.
int main( void )
int main( int argc, char *argv[] )
int main( int argc, char *argv[], char *envp[] )
あまり馴染みのないenvp
という変数が出てきました. これは環境変数へのポインターの配列です. 先程しれっと登場した__environ
がこれに該当します.
Q. 引数1個や4個以上は無理なの?
A. いけます
「main
」というシンボルさえ定義されていれば, 引数がどうであってもプログラムを生成することは可能です. すなわち, main(int ,char*[])
である必要はなく, main(int, int)
であっても処理系はちゃんとmain関数として取り扱うわけです. もちろん, main(a,b,c,d,e,f,g){}
のようなコードでも大丈夫です.
シェルからプログラムへ
main関数からコマンドライン引数を読み込む手順はわかったが, シェルからプログラムへはどのように渡しているのだろうか.
40284a: 8b 7c 24 0c mov edi,DWORD PTR [rsp+0xc]
40284e: 48 8b 74 24 10 mov rsi,QWORD PTR [rsp+0x10]
argc
とargv
に対応するレジスタから遡っていく
4022e0: 48 89 54 24 10 mov QWORD PTR [rsp+0x10],rdx
--- 省略 ---
4022f1: 89 74 24 0c mov DWORD PTR [rsp+0xc],esi
__libc_start_mainの先頭らへんでrsp+0x10
のメモリにrdx
, rsp+0xc
のメモリにesi
を格納している.
401c69: 5e pop rsi
401c6a: 48 89 e2 mov rdx,rsp
__libc_start_mainに飛ぶ前に_startでrsi
にスタックからpop
し, rdx
にrsp
を格納している. つまり_startのスタックポインタの先頭にargc
があり, sp+4
の位置にargvの先頭アドレスが格納されていたことになる. このことから, シェルがプログラムを起動する際に, コマンドライン引数の個数とコマンドライン引数として渡された文字列へのポインタをスタックにpushしていると予想できます.
実際にgdb
で_start
にbreakポイントを設定すると, スタックにargc
,argv
,envp
があるのが確認できる. また, libc/glibc/sysdeps/x86_64/start.S
のコメントにも詳しく書かれています.
プログラムの実行
シェルがプログラムを実行するとき, fork -> execveシステムコールの流れで行われる.
execveシステムコールが発行された後のカーネルの作業は以下のようなものがある.
- 実行ファイルを読み込み, 仮想メモリ上にマッピングする
- argc/argv[], BSSの初期化, 環境変数の引き渡し
- 実行ファイル上のエントリ・ポイントから実行を開始する
カーネルがelfファイルをロードする際にスタック上にargcを積み, その直下にargv[]を作成しています.
ポインタの配列の実体をスタック上に作成しているので, アプリケーションのスタートアップには, 配列へのポインタが渡されるわけではありません.
仮引数を書かずにコマンドライン引数を取得
シェルがコマンドライン引数への実体をスタックへ格納しているという事は, 仮引数を書かなくてもアクセスできるかもしれません.
argcからargvにアクセス
int main(int argc) {
return argc;
}
このプログラムをコンパイルし, 逆アセンブルすると
0000000000401d85 <main>:
401d85: 55 push rbp
401d86: 48 89 e5 mov rbp,rsp
401d89: 89 7d fc mov DWORD PTR [rbp-0x4],edi
401d8c: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
401d8f: 5d pop rbp
401d90: c3 ret
スタックにはargc
しか確保されていません. しかし, _start
と__libc_start_main
ではきちんとargv
が存在するように振る舞っています. つまり, __libc_start_mainセクションの状態のスタックポインタ+0x10のアドレスにargvのポインタがあるはずです.
すなわち, argc
のアドレス+0x4(mainセクションでのargc
のrbp
からのオフセット)+0x8(psuh rbp
で下がったrsp分)+0x8(call
でpushされた戻りアドレス分)+0x10(__libc_start_mainセクションでのrsp
からのオフセット)をすればアクセスできそうです.
#include <stdio.h>
int main(int argc) {
printf("%s\n", **((char***)((char*)&argc+0x4+0x8+0x8+0x10)));
return 0;
}
$ gcc test.c -static
$ ./a.out
./a.out
argc
を元にargvの要素へアクセスすることができました!
main(void)
次に, main関数内でスタックに変数を確保していない場合のアクセス方法です.
argv[0]
のアドレスを事前に取得し, 直接アクセスすればいけそうな気がします.
#include <stdio.h>
int main(int argc, char **argv) {
printf("%p\n", &*argv[0]);
return 0;
}
$ ./a.out
0x7ffeb3f5d2e7
$ ./a.out
0x7fffda4682e7
しかし, 実行するたびにアドレスが変わります.
これにはASLRの影響が考えられます.
ASLR
ASLR(Address Space Layout Randomisation)とは, メモリ上のスタック等の配置を実行毎にランダムにする機能です.
スタックオーバーフロー等の脆弱性をついた攻撃を成功させずらくすることができます.
以下のコマンドで無効化します
$ sudo sysctl -w kernel.randomize_va_space=0
すると, argv
のアドレスが固定化されます.
#include <stdio.h>
int main(int argc, char **argv) {
printf("%p\n", &*argv[0]);
return 0;
}
$ ./a.out
0x7fffffffe2e7
$ ./a.out
0x7fffffffe2e7
このアドレスに直接アクセスすることでargvを参照することができました!
#include <stdio.h>
int main(void) {
printf("%s\n", 0x7fffffffe2e7);
return 0;
}
$ ./a.out
./a.out
実験が終わったら必ず以下のコマンドでASLRを有効にしましょう.
$ sudo sysctl -w kernel.randomize_va_space=2
まとめ
コマンドライン引数がどのような仕組みで用意され, アクセスされるのか掘り下げてきました.
main関数でも他の関数と同様に仮引数はABIに従った方法で受け取るが, main関数に渡す側の__libc_start_mainの処理内容は変わらないことや, コマンドライン引数の実体もスタック上にあることがわかりました.