LoginSignup
1
1

More than 1 year has passed since last update.

C言語のコマンドライン引数を掘り下げる

Last updated at Posted at 2021-05-09

はじめに

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関数のargcargvは仮引数となります.
では, どのようにmain関数にコマンドライン引数を渡しているのでしょうか.

main関数の呼び出し元

とりあえずobjdumpで逆アセンブルしてみましょう.

test.c
// 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と検索して合致したセクションのみ抜粋しました.
この結果を見ると, _startrdimainのアドレスを入れ, __libc_start_mainを呼び,
hlt(CPUの動作を停止)しているのがわかります. つまり, __libc_start_mainでmain関数を呼んでいるということですね.

__libc_start_mainの中身

__libc_start_mainの中では何を行っているのでしょうか.

_start
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セクションの処理も見ることができます.
長いのでここでは要点のみ掲載します.

_start
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を呼び出しています.

__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が入っています.

最後にedieaxを格納し, 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関数からコマンドライン引数を読み込む手順はわかったが, シェルからプログラムへはどのように渡しているのだろうか.

__libc_start_main
40284a:       8b 7c 24 0c             mov    edi,DWORD PTR [rsp+0xc]
40284e:       48 8b 74 24 10          mov    rsi,QWORD PTR [rsp+0x10]

argcargvに対応するレジスタから遡っていく

__libc_start_main
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を格納している.

_start
401c69:       5e                      pop    rsi
401c6a:       48 89 e2                mov    rdx,rsp

__libc_start_mainに飛ぶ前に_startでrsiにスタックからpopし, rdxrspを格納している. つまり_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セクションでのargcrbpからのオフセット)+0x8(psuh rbpで下がったrsp分)+0x8(callでpushされた戻りアドレス分)+0x10(__libc_start_mainセクションでのrspからのオフセット)をすればアクセスできそうです.

test.c
#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の処理内容は変わらないことや, コマンドライン引数の実体もスタック上にあることがわかりました.

参考

1
1
6

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1