グローバル変数を横から覗く

この記事はカレーのち ぴょこりんクラスタ Advent Calendar 2018のために書いたものです。

サマリ

Linux/proc/${pid}/memを使って、グローバル変数を読み書きするものを作りました。 ptraceを使わなくても読み書きできます。使い方は以下のとおりで、WRITE_VALUEを省略すると リード、WRITE_VALUEを書くとライトします。1、2、4、8バイトの変数に対応できますが、 配列の途中とか構造体のメンバ変数はまだダメです。ELFバイナリを読むので、 C/C++などで作ったバイナリでないと動きません。

# ./gvtool.py </path/to/binary> <PID> <GLOBAL_VARIABLE_NAME> {<WRITE_VALUE>}

ソースコードはこちら。 github.com

動作はこんな感じです。

背景など

プログラムをチューニングする場合、パラメタをどこに置くかは結構悩ましい問題ですが、 ともかく手を抜くことを考えた場合、グローバル変数にえいやと書いてしまうことがあります*1

そのグローバル変数gdbなどのデバッガで書き換えながらいろいろ動作させるんですが、 デバッガを使うのが億劫なケースがたまにあります。例えば、他のプログラムと連動するようなものをチューニングする場合です。 デバッガを使う場合は、変数書き換え時に逐一プログラムをbreakする必要があるので、 タイミングによっては連動する他のプログラムが異常終了してしまうことがあります。 また、デバッガはインタラクティブに動かす必要があったりで、自動化がちょっとやりにくいというのもいまいちですね。

そういうちょっとした面倒さを解決するために、今回このgvtool.pyを作りました。 pet.py*2がELFを読むためのもの、gvtool.pyが/proc/${pid}/mapsを読んだり/proc/${pid}/memを読み書きするためのものです。

どうやって実現しているか

概要

要はメモリのある一部を書き換えるだけなので、メモリアドレス(=先頭アドレスとオフセット)さえわかればいいわけです。 つまり、シンプルに以下の3ステップでできます。

  • ELFバイナリのシンボルテーブルからアドレスのオフセットを求める
  • /proc/${pid}/mapsで先頭アドレスを調べる
  • 先頭アドレスとオフセットがわかったので、/proc/${pid}/memを読み書きする

ELF: Executable and Linkable Format

Linuxで採用している実行可能なバイナリのフォーマットです。 ELFには、シンボルテーブルと呼ばれる、変数の名前とメモリ空間上のオフセットを 保持しているセクションがありますので、そこを直接読みます。

シンボルテーブルのエントリのフォーマットは、 身近なところだと/usr/include/elf.hに載ってます。64bitだと以下のようになっていて、 ここのst_valueのところがアドレスオフセットになります*3

typedef struct
{
  Elf64_Word    st_name;        /* Symbol name (string tbl index) */
  unsigned char st_info;        /* Symbol type and binding */
  unsigned char st_other;       /* Symbol visibility */
  Elf64_Section st_shndx;       /* Section index */
  Elf64_Addr    st_value;       /* Symbol value */
  Elf64_Xword   st_size;        /* Symbol size */
} Elf64_Sym;

シンボルテーブルはデバッグ情報ではないので、-gをつけてコンパイルしていなくても読めます。

/proc/${pid}/maps

Linux Kernelが提供する、プロセスのメモリマップを教えてくれる疑似ファイルです。 例えばこんな感じで見えます。左から、

  • メモリの開始アドレス
  • メモリの終端アドレス
  • パーミッション
  • ファイルのオフセット
  • バイス番号
  • inode
  • ファイルパス

です。詳しくはここ

/home/bisco% cat /proc/$(pidof a.out)/maps
5591a7f3e000-5591a7f3f000 r--p 00000000 00:15 103135   /home/bisco/test/a.out
5591a7f3f000-5591a7f40000 r-xp 00001000 00:15 103135   /home/bisco/test/a.out
5591a7f40000-5591a7f41000 r--p 00002000 00:15 103135   /home/bisco/test/a.out
5591a7f41000-5591a7f42000 r--p 00002000 00:15 103135   /home/bisco/test/a.out
5591a7f42000-5591a7f43000 rw-p 00003000 00:15 103135   /home/bisco/test/a.out
5591a9395000-5591a93b6000 rw-p 00000000 00:00 0        [heap]
7f32b4401000-7f32b4423000 r--p 00000000 00:15 46396    /usr/lib/libc-2.28.so
7f32b4423000-7f32b456e000 r-xp 00022000 00:15 46396    /usr/lib/libc-2.28.so
7f32b456e000-7f32b45ba000 r--p 0016d000 00:15 46396    /usr/lib/libc-2.28.so
7f32b45ba000-7f32b45bb000 ---p 001b9000 00:15 46396    /usr/lib/libc-2.28.so
7f32b45bb000-7f32b45bf000 r--p 001b9000 00:15 46396    /usr/lib/libc-2.28.so
7f32b45bf000-7f32b45c1000 rw-p 001bd000 00:15 46396    /usr/lib/libc-2.28.so
7f32b45c1000-7f32b45c7000 rw-p 00000000 00:00 0
7f32b45cf000-7f32b45d1000 r--p 00000000 00:15 46385    /usr/lib/ld-2.28.so
7f32b45d1000-7f32b45f0000 r-xp 00002000 00:15 46385    /usr/lib/ld-2.28.so
7f32b45f0000-7f32b45f8000 r--p 00021000 00:15 46385    /usr/lib/ld-2.28.so
7f32b45f8000-7f32b45f9000 r--p 00028000 00:15 46385    /usr/lib/ld-2.28.so
7f32b45f9000-7f32b45fa000 rw-p 00029000 00:15 46385    /usr/lib/ld-2.28.so
7f32b45fa000-7f32b45fb000 rw-p 00000000 00:00 0
7fff149b7000-7fff149d8000 rw-p 00000000 00:00 0        [stack]
7fff149d8000-7fff149db000 r--p 00000000 00:00 0        [vvar]
7fff149db000-7fff149dd000 r-xp 00000000 00:00 0        [vdso]

グローバル変数は、heapでもstackでもなく、bssという領域にあります。 bssという領域は読み書きできるので、上から5番目の領域にいそうだな、ということが何となくわかりますね。

シンボルテーブルに載っているオフセットは、x番目の領域のオフセットではなく、先頭からのオフセットです。 a.outのグローバル変数にアクセスしたければ、a.outの先頭アドレス(例で言うところの0x5591a7f3e000)がわかればよいです。 この先頭アドレスに、 シンボルテーブルから得られたオフセットを足せばメモリアドレスがわかります。

/proc/${pid}/mem

Linux Kernelが提供する、プロセスのメモリ空間へアクセスするための疑似ファイルです。 setuid-rootなバイナリ(例えば実行者がrootとか)であれば、 プロセス${PID}をbreakすることなくメモリの読み書きが可能になります。 書き込みが可能になったのはKernel 2.6.38-rc8(2011/03/08)からであり、 昨今の大抵のディストリビューションで使えます。

以下、LKMLより抜粋。デバッグ用途で使ってくれとのことみたいですね。

  This patch series enables safe writes to /proc/pid/mem.  Such functionality is
  useful as it gives debuggers a simple and efficient mechanism to manipulate a
  process' address space.  Memory can be read and written using single calls to
  pread(2) and pwrite(2) instead of iteratively calling into *ptrace(2)* 
  In addition, /proc/pid/mem has always had write permissions enabled, so clearly it
  *wants* to be written to

まとめ

  • /proc/${pid}/mem/proc/${pid}/maps、シンボルテーブルの情報があれば、動いているプログラムのグローバル変数を読み書きできる

*1:そんなことはしないのが一番だと思いますが

*2:Python Elf Tool

*3:バイナリの種類によっては意味が違いますが、ここでは触れません