Explore "Full-Stack" in depth!

情報系の専門学校で、今は機械学習に的を絞って学習中。プログラミングを趣味でやりつつ、IT系のあらゆる知識と技術を身に付けるべく奮闘中。

C言語の文字列はどこに?-ELFバイナリに見る実行プログラムの挙動-

目次

概要

※注意!

Twitterでもご指摘があったように、
本記事はC言語の習熟度が低い人間
いろいろ試行錯誤しながらまとめたものになります。

あくまで勉強の一環として
作成した記事であるという事を前提に考えていただければと思います。

C言語で文字列の扱いにこまるという方は多いです。
私もコードを書いていてよくSIGSEGVで落ちることがあります。
それは単純にC言語に慣れていないだけなんですが…。

今回は自身が言語処理系の勉強をしているだけあって、
C言語は文字列をどのように管理しているか?という疑問を持ちました。

様々なコードを書きながら、
ELFバイナリがどのように文字列を保有しているのかについて探っていきます。

以下の点に注意して読み進めてください。

  • 指摘された内容については修正しましたが、正確性に欠ける可能性があります。
  • 示されるソースコードについてはC言語の優れたコーディング方法を示すというわけではない
    • あくまで 挙動を確認するため

.dataに格納される文字列

まずはグローバル空間に文字列変数を定義してみます。

#include <stdio.h>
char name[] = "abc";
int main(void) {
    printf("%s\n", name);
    return 0;
}

main関数内では文字列abcを出力するようなプログラムを書いています。

C言語は文字列リテラルを使用すれば自動的にnullバイト\0が挿入されるので、

printf("%s\n",name);

のようなコードを書いても三文字分しっかりと出力してくれるわけです。

それでは、このコードをコンパイルし、
gdbを用いてより深掘りしてみましょう。

$ gdb -q a.out
gdb-peda$ start

f:id:orangebladdy:20190503085321j:plain

push    rbp
mov     rbp,rsp

という関数プロローグは置いておいて、
次に実行しようとしている命令は

lea rdi,[rip+0x2009cb] #0x8201010 <name>

というアセンブリ命令のようですね。
このメモリアドレス`0x8201010から( 実効アドレス )をロードすると、

f:id:orangebladdy:20190503085752j:plain

のように、rdiabcという文字列がロードされています。

x86_64ではリトルエンディアンなので、
0x636261という順番で格納されていますね。

さて、この実行プログラムは至ってシンプルなものですが、
つまりabcという文字列は0x8201010既に格納されていたことに成ります。

このメモリアドレスはどこを指しているのでしょうか?

まずmain関数のエントリーポイントは0x800063aですよね。
これはgdbの出力からも確認することが出来ます。

ではこのELFバイナリにreadelfコマンドを使用して、
シンボルテーブルをダンプしてみましょう。

この美也は動的リンクなのでダイナミックシンボルテーブルが存在しますが、
今回はELFリンカ・ローダの挙動を確認するわけではないので省略します。

さて、実行結果を下に示します。

Symbol table '.symtab' contains 64 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000238     0 SECTION LOCAL  DEFAULT    1
     2: 0000000000000254     0 SECTION LOCAL  DEFAULT    2
     3: 0000000000000274     0 SECTION LOCAL  DEFAULT    3
     4: 0000000000000298     0 SECTION LOCAL  DEFAULT    4
     5: 00000000000002b8     0 SECTION LOCAL  DEFAULT    5
     6: 0000000000000360     0 SECTION LOCAL  DEFAULT    6
     7: 00000000000003e2     0 SECTION LOCAL  DEFAULT    7
     8: 00000000000003f0     0 SECTION LOCAL  DEFAULT    8
     9: 0000000000000410     0 SECTION LOCAL  DEFAULT    9
    10: 00000000000004d0     0 SECTION LOCAL  DEFAULT   10
    11: 00000000000004e8     0 SECTION LOCAL  DEFAULT   11
    12: 0000000000000500     0 SECTION LOCAL  DEFAULT   12
    13: 0000000000000520     0 SECTION LOCAL  DEFAULT   13
    14: 0000000000000530     0 SECTION LOCAL  DEFAULT   14
    15: 00000000000006d4     0 SECTION LOCAL  DEFAULT   15
    16: 00000000000006e0     0 SECTION LOCAL  DEFAULT   16
    17: 00000000000006e4     0 SECTION LOCAL  DEFAULT   17
    18: 0000000000000720     0 SECTION LOCAL  DEFAULT   18
    19: 0000000000200db8     0 SECTION LOCAL  DEFAULT   19
    20: 0000000000200dc0     0 SECTION LOCAL  DEFAULT   20
    21: 0000000000200dc8     0 SECTION LOCAL  DEFAULT   21
    22: 0000000000200fb8     0 SECTION LOCAL  DEFAULT   22
    23: 0000000000201000     0 SECTION LOCAL  DEFAULT   23
    24: 0000000000201014     0 SECTION LOCAL  DEFAULT   24
    25: 0000000000000000     0 SECTION LOCAL  DEFAULT   25
    26: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS crtstuff.c
    27: 0000000000000560     0 FUNC    LOCAL  DEFAULT   14 deregister_tm_clones
    28: 00000000000005a0     0 FUNC    LOCAL  DEFAULT   14 register_tm_clones
    29: 00000000000005f0     0 FUNC    LOCAL  DEFAULT   14 __do_global_dtors_aux
    30: 0000000000201014     1 OBJECT  LOCAL  DEFAULT   24 completed.7696
    31: 0000000000200dc0     0 OBJECT  LOCAL  DEFAULT   20 __do_global_dtors_aux_fin
    32: 0000000000000630     0 FUNC    LOCAL  DEFAULT   14 frame_dummy
    33: 0000000000200db8     0 OBJECT  LOCAL  DEFAULT   19 __frame_dummy_init_array_
    34: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS c.c
    35: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS crtstuff.c
    36: 0000000000000824     0 OBJECT  LOCAL  DEFAULT   18 __FRAME_END__
    37: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS
    38: 0000000000200dc0     0 NOTYPE  LOCAL  DEFAULT   19 __init_array_end
    39: 0000000000200dc8     0 OBJECT  LOCAL  DEFAULT   21 _DYNAMIC
    40: 0000000000200db8     0 NOTYPE  LOCAL  DEFAULT   19 __init_array_start
    41: 00000000000006e4     0 NOTYPE  LOCAL  DEFAULT   17 __GNU_EH_FRAME_HDR
    42: 0000000000200fb8     0 OBJECT  LOCAL  DEFAULT   22 _GLOBAL_OFFSET_TABLE_
    43: 00000000000006d0     2 FUNC    GLOBAL DEFAULT   14 __libc_csu_fini
    44: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterTMCloneTab
    45: 0000000000201000     0 NOTYPE  WEAK   DEFAULT   23 data_start
    46: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND puts@@GLIBC_2.2.5
    47: 0000000000201014     0 NOTYPE  GLOBAL DEFAULT   23 _edata
    48: 00000000000006d4     0 FUNC    GLOBAL DEFAULT   15 _fini
    49: 0000000000201010     4 OBJECT  GLOBAL DEFAULT   23 name
    50: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@@GLIBC_
    51: 0000000000201000     0 NOTYPE  GLOBAL DEFAULT   23 __data_start
    52: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
    53: 0000000000201008     0 OBJECT  GLOBAL HIDDEN    23 __dso_handle
    54: 00000000000006e0     4 OBJECT  GLOBAL DEFAULT   16 _IO_stdin_used
    55: 0000000000000660   101 FUNC    GLOBAL DEFAULT   14 __libc_csu_init
    56: 0000000000201018     0 NOTYPE  GLOBAL DEFAULT   24 _end
    57: 0000000000000530    43 FUNC    GLOBAL DEFAULT   14 _start
    58: 0000000000201014     0 NOTYPE  GLOBAL DEFAULT   24 __bss_start
    59: 000000000000063a    23 FUNC    GLOBAL DEFAULT   14 main
    60: 0000000000201018     0 OBJECT  GLOBAL HIDDEN    23 __TMC_END__
    61: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMCloneTable
    62: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@@GLIBC_2.2
    63: 00000000000004e8     0 FUNC    GLOBAL DEFAULT   11 _init

この事から、

main関数は実行プログラムからオフセット0x63aに位置している事がわかります。

つまりこのプログラムは
0x8000かsラ始まるメモリにロードされているということですね。

そしてこの出力結果には、
nameというシンボル名が存在します。

49: 0000000000201010     4 OBJECT  GLOBAL DEFAULT   23 name

これで0x8201010の意味はわかりましたね。

objdumpコマンドでも確認してみましょう。

$objdump -D -M intel a.out | grep -A20 .data
Disassembly of section .data:

0000000000201000 <__data_start>:
        ...

0000000000201008 <__dso_handle>:
  201008:       08 10                   or     BYTE PTR [rax],dl
  20100a:       20 00                   and    BYTE PTR [rax],al
  20100c:       00 00                   add    BYTE PTR [rax],al
        ...

0000000000201010 <name>:
  201010:       61                      (bad)
  201011:       62                      .byte 0x62
  201012:       63 00                   movsxd eax,DWORD PTR [rax]

0x61,0x62,0x63はそれぞれabcを示しています。
最後にその後nullバイトの\0が挿入されていますね。

abcの居場所を確認できた所で、
ここまで見てきた事をまとめてみましょう。

  • グローバル空間の文字列.dataセクション内に置かれている
    • objdumpコマンド、readelfコマンドによりその存在を確認できる
  • レジスタにロードされる際にリトルエンディアン形式に変換される。

余談ですが、
gcc -S -masm=intel -fno-asynchronous-unwind-tablesコマンドでコンパイルした結果を見てみると、

        .file   "c.c"
        .intel_syntax noprefix
        .text
        .globl  name ;グローバルであることを示唆
        .data
        .type   name, @object
        .size   name, 4 ;サイズ a+b+c+\0の4バイト
name: ;nameシンボル
        .string "abc"
        .text
        .globl  main
        .type   main, @function
main:
        push    rbp
        mov     rbp, rsp
        lea     rdi, name[rip]
        call    puts@PLT
        mov     eax, 0
        pop     rbp
        ret
        .size   main, .-main
        .ident  "GCC: (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0"
        .section        .note.GNU-stack,"",@progbits

のように、
グローバル変数についての情報が確認できます。


.dataセクションとは

次の解析に進む前に、
ここでは.dataセクションの主な役割について見ていきます。

このセクションには0以外の初期値を持つ大域変数
0以外の初期値を持つ静的局所変数が格納されます。

実際に見てみましょう。
前者は既に確認したので、後者について検証してみます。

#include<stdio.h>
int main(void){
    static int loc = 30:
    printf("%d\n",loc);
    return 0;
}

f:id:orangebladdy:20190503093518j:plain

raxにロードされた0x1e = 30は、
0x8201010<loc.2250>の値であることがわかります。

次のようなコードも検証してみましょう。

#include <stdio.h>
void plus_one(void);
int main(void) {
    plus_one();
    plus_one();
    plus_one();
    return 0;
}

void plus_one(void) {
    static int loc = 30;
    loc++;
    printf("%d\n", loc);
}

出力結果は次のように、

31
32
33

となります。

この時locは、

f:id:orangebladdy:20190503094524j:plain

三回目のplus_one呼び出しであり、加算前なので0x20=32

となっています。

アセンブリ命令を読むとわかりますが、
.data領域に加算した値を書き込んでいます。

セクションに書き込み可能かどうかという情報は、
ELFバイナリにおけるセクションヘッダが保有しています。

readelf -S a.outの結果を見てみましょう。

f:id:orangebladdy:20190503095407j:plain

アルファベットでAとかWAとかあるのが見えるでしょうか。
これはそれぞれ次のような情報を持っています。

  • W…このセクションは(プロセス実行中)書込み可能なデータを含む
  • A…このセクションはプロセス実行中メモリを占有する
  • X…このセクションは実行可能な機械語命令を含む
    • ご存知 .textセクション 等がこのフラグを持ちます。
  • M…プロセッサ固有の為に予約されたマスクビット

.dataセクションはWフラグを保持しているので、
書き込みが可能というわけですね。


.rodataに書き込まれる文字列

.dataについての理解が深まった所で、
次に.rodataについて見ていきます。

Read Onlyの示すとおり、
このセクションに配置されるのは読み込み専用の文字列です。

次のようなコードを書きます。
このコードは非常に大きな問題を抱えており、
勿論Segmentation faultで落ちます。

#include <stdio.h>
#include <string.h>
void crack(char *name) {
    strncpy(name, "DRUMATO", strlen(name));
}
int main(void) {
    const char *name = "drumato";
    crack(name);
    return 0;
}

同じくgdbで追っていきます。

f:id:orangebladdy:20190503101600j:plain

0x800077c からdrumatoという文字列を読み出しているようです。
objdumpで確認。

Disassembly of section .rodata:

0000000000000770 <_IO_stdin_used>:
 770:   01 00                   add    DWORD PTR [rax],eax
 772:   02 00                   add    al,BYTE PTR [rax]
 774:   44 52                   rex.R push rdx
 776:   55                      push   rbp
 777:   4d                      rex.WRB
 778:   41 54                   push   r12
 77a:   4f 00 64 72 75          rex.WRXB add BYTE PTR [r10+r14*2+0x75],r12b
 77f:   6d                      ins    DWORD PTR es:[rdi],dx
 780:   61                      (bad)
 781:   74 6f                   je     7f2 <__GNU_EH_FRAME_HDR+0x6e>

0x6472756d61746fで、
drumatoという文字列になります。

ちょうど77cから文字列が始まっていますね。

まぁ既にお察しだとは思うですが、
Read Onlyなので先述したWフラグが立っていません。

そのセクションに書き込みをしようとしたので
SIGSEGVが発信されて落ちたっていうお話です。

因みに上記コードは const修飾されていなくても、
つまり下記に示すように

#include <stdio.h>
#include <string.h>
void crack(char *name) { strncpy(name, "DRUMATO", strlen(name)); }
int main(void) {
    char *name = "drumato";
    crack(name);
    return 0;
}

のようになっていてもdrumatoは文字列リテラルなので、
.rodataに埋め込まれます。
実際に書き換えたいのであれば、

#include <stdio.h>
#include <string.h>
void crack(char *name) { strncpy(name, "DRUMATO", strlen(name)); }
int main(void) {
    char name[] = "drumato";
    crack(name);
    return 0;
}

のようにする必要があります。
ではなぜこのコードでは成功するのでしょうか?
次にその理由を見ていきましょう。


x64命令セット、movabs

上記コードをコンパイルしますが、
Stack Protectorが追加で命令を挿入するために
アセンブリコードが複雑になってしまいます。
ここでは
gcc -S -masm=intel -fno-asynchronous-unwind-tables -fno-stack-protector
コマンドでコンパイルします。

        .file   "c.c"
        .intel_syntax noprefix
        .text
        .section        .rodata
.LC0:
        .string "DRUMATO"
        .text
        .globl  crack
        .type   crack, @function
crack:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     QWORD PTR -8[rbp], rdi
        mov     rax, QWORD PTR -8[rbp]
        mov     rdi, rax;drumatoの文字数を測る
        call    strlen@PLT
        mov     rdx, rax
        mov     rax, QWORD PTR -8[rbp]
        lea     rsi, .LC0[rip]; 変更後文字列、DRUMATOのロード
        mov     rdi, rax
        call    strncpy@PLT
        nop
        leave
        ret
        .size   crack, .-crack
        .globl  main
        .type   main, @function
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        movabs  rax, 31371684211946084 ;即値ロード
        mov     QWORD PTR -8[rbp], rax ;スタックに積む
        lea     rax, -8[rbp] 
        mov     rdi, rax
        call    crack 
        mov     eax, 0 ;正常終了
        leave
        ret
        .size   main, .-main
        .ident  "GCC: (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0"
        .section        .note.GNU-stack,"",@progbits

アセンブリ自体は特に難しくありません。
今回注目すべきなのは、

movabs  rax, 31371684211946084

という命令です。
これはx64拡張の命令であり、
IA-32アーキテクチャ等ではサポートされていません。
この値は10進数で表示されていて見づらいので、
16進数に変換したいと思います。

#Pythonのコード
print(hex(31371684211946084)) #=>'0x6f74616d757264'

無事drumatoのASCIIコードが確認できました。
つまり、

char name[] = "drumato";等のようにした場合は、
文字列がバイナリに埋め込まれる事なく
コンパイル時に展開されるということです。

今までと違いバイナリ上のエントリーポイントを指定したロードなどを行うわけではなく、
あくまでレジスタ上にロードし、
スタックの値を書き換えるなどしているに過ぎないので、
Segmentation faultは発生しません。


おまけ:mallocによるヒープ領域のアロケート

ここまでに得た知識を活用すれば、
下のコードが内部でどのように動いているかを想像することが出来ます。

ご指摘を頂いたのでコードを修正しました。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
    char name[] = "DRUMATO";
    char *buf   = (char *)malloc(strlen(name) + 1);
    strcpy(buf, name);
    printf("%s\n", buf);
    return 0;
}

まず先述したように、
char name[] = "drumato";によって、
スタックには即値0x6f74616d757264が格納されます。
勿論リトルエンディアン形式です。

strlen()関数は第一引数をrdiに期待します。

        movabs  rax, 22329162607186500
        mov     QWORD PTR -16[rbp], rax
        lea     rax, -16[rbp]
        mov     rdi, rax
        call    strlen@PLT

のようにしてロードしています。

同じくmalloc()は第一引数をrdiに期待します。

malloc()の呼び出しから戻ってくるとraxには、
アロケートしたヒープ領域の先頭アドレスが格納されています。

総評

ELFバイナリ、アセンブリを細かくチェックしながら、
C言語の文字列表現について深く掘り下げました。