Goのプログラムで動的アップデートについてのトリビア
この記事は
Go3 Advent Calendar 2018 - Qiita の21日目の記事にあたります。
アドベントカレンダーに参加するのは初めてのため、作法なので間違っているところがあったらすいません。
動的アップデート
動的アップデートという言葉をご存知でしょうか?ホットリロードだったりモンキーパッチだったり似たような意味で色々言い方があるかもしれませんがACMやIEEEの論文を眺めているとDSU(Dynamic Software Update)と書いてることが多いです。
何をするかというと プログラムを中断させずにプログラムのアップデートを行う ことです。
具体的にはプロセスのメモリ空間を外部から書き換えて挙動を書き換えます。linuxにはlivepatchというツールがありますが、これはプロセスを指定し、アドレスを指定するとそのアドレスの値を書き換えることができます。要は黒魔術の一種です。
今回はGoで書かれたプログラムをGoでパッチ(厳密には違います)を書いて動的にプログラムの挙動を書き換える ということができるのかというのを検証してみます。
一般的な動的アップデートの手法
よく知られるDSUの手法を図に示します。
よくあるのが関数呼び出しで本来とは別の関数を呼び出すものです。
livepatchでは関数を呼び出したあと、直後のメモリ上の命令をjmp命令に書き換えてアップデート対象の関数アドレスに飛びます。 今回はこれを行います。
今回やってみるアップデートのフロー
- 自身を動的に書き換えるgoのプログラムを書く(アップデート対象のプログラム: target)
- 新しい関数を定義しただけのプログラムを書いてコンパイルする(パッチ: patch)
- targetはpatchのバイナリからアップデート対象となる関数のバイナリを抽出し、自身で呼び出している関数を書き変える
前提条件
実装
以上の手順を行うコードを見ていきます。
target.go
package main import ( "fmt" "log" "github.com/Bo0km4n/lupus" ) func f1() int { return 1 } func main() { targetFunc := f1 if err := lupus.PatchFunction( "/home/vagrant/go/src/github.com/Bo0km4n/lupus/example/patch", "main.f2", targetFunc, ); err != nil { log.Fatal(err) } fmt.Println(targetFunc()) }
patch.go
package main func f2() int { return 2 } func main() { f2() }
targetのプログラムは普通なら1が表示されますが、patchを当てることにより2が表示されます。
f2のアセンブラはこのようになります
00000000004ce1c0 <main.f2>: 4ce1c0: 48 c7 44 24 08 01 00 movq $0x2,0x8(%rsp) 4ce1c7: 00 00 4ce1c9: c3 retq
このバイト配列をうまいこと抽出して書き換えてあげればokです。あとこのアセンブラは後ほど関わってきますので少しだけ覚えていおいてください。
lupus.PatchFunction
はgoのmonkeypatchライブラリを参考に関数の具体的な置き換えを行います。
PatchFunction
アップデートのロジックを順に追っていきます。
func PatchFunction(patchObjPath string, funcName string, target interface{}) error { elfFile, _ := open(patchObjPath) newFuncBytes := getFuncBytes(elfFile, funcName) newFuncBytes, err := writeFuncVal(newFuncBytes) if err != nil { return err } patchValue(reflect.ValueOf(target), newFuncBytes) return nil }
まず、引数でもらったバイナリファイルを開いてElfファイルのセクションヘッダーを取れるようパースします。
open
関数の中では goの debug/elf
パッケージを用いてファイルを開いています。
getFuncBytes
でセクションヘッダとシンボルテーブルからバイナリ内での対象の関数のアドレスを算出して関数の実体となるバイト配列を返しています。
writeFuncVal
の内部でバイト配列をtmpファイルに書き出してファイルポインタをmmapシステムコールに渡してヒープ領域(多分)に確保します。
ここでnewFuncBytesを再代入しているのはwriteFuncVal実行後に返すバイト配列は中身は一緒ですがポインタが新しく確保したメモリのアドレスに書き換わっているからです。
つまり、関数の実行後にこの配列のアドレスにjmp命令でふっ飛ばせばokなわけです。
次の patchValue
に新しい関数のバイト配列と対象となる関数を渡して実行するとメモリが書き換わります。
パッチのコア部分の実装
ではパッチでメモリを書き換える処理の実装について見てみます。
patchValue
func patchValue(target reflect.Value, replaceValue []byte) { if target.Kind() != reflect.Func { log.Fatal("Target has to be a Func") } replace(target.Pointer(), replaceValue) }
ここで行うのはreflectパッケージで渡された関数ポインタがGoの関数であるかをチェックしているだけです。
replace
func replace(orig uintptr, replacement []byte) { replacePtr := *(*uintptr)(unsafe.Pointer(&replacement)) jumpData := assembleJump(replacePtr) copyToLocation(orig, jumpData) }
一行ずつ追っていきます。
replacePtrは先程確保したバイト配列のメモリ領域の先頭アドレスになります。
jumpDataは
func assembleJump(to uintptr) []byte { return []byte{ 0x48, 0xBA, byte(to), byte(to >> 8), byte(to >> 16), byte(to >> 24), byte(to >> 32), byte(to >> 40), byte(to >> 48), byte(to >> 56), // movabs rdx,to 0xFF, 0xe2, // jmp rdx } }
このような処理を行い、
movabs rdx, to_address(バイト配列のアドレス) jmp rdx
になる命令のバイト配列を示しています(ここはアーキテクチャ依存)。
最後にcopyToLocationでメモリ上の対象関数の命令を書き換えます。
func copyToLocation(location uintptr, data []byte) { f := rawMemoryAccess(location, len(data)) page := rawMemoryAccess(pageStart(location), syscall.Getpagesize()) err := syscall.Mprotect(page, syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC) if err != nil { panic(err) } copy(f, data[:]) }
fはアップデート対象の関数が始まるアドレスから、パッチの長さ分のメモリ領域を取得します。 そして、対象のアドレスからpageサイズ分のアドレスを取得し、mprotectシステムコールを用いて4096バイト分(linuxのページサイズ)の権限を書き換えさせてもらいます。
これでメモリの書き換えと書き換え後の命令実行ができるようになりました。
最後にcopy関数で対象関数に入ったあと実行するはずだった命令の部分をjmp命令に書き換えて終わりです。
内部での関数呼び出し
今回のパッチは返す値を1から2にしただけです。 では次にこのような関数に処理を書き換えることにします。
func newFunc() int { fmt.Println("new func called") return 2 }
fmt.Printlnが追加されました。
これでパッチを当てようとしてみます。
すると。。。
unexpected fault address 0x7f0de5040fb0 fatal error: fault [signal SIGSEGV: segmentation violation code=0x1 addr=0x7f0de5040fb0 pc=0x7f0de5040fb0] goroutine 1 [running]: runtime.throw(0x4e4521, 0x5) /usr/lib/go-1.10/src/runtime/panic.go:616 +0x81 fp=0xc420041dc0 sp=0xc420041da0 pc=0x426ca1 runtime: unexpected return pc for runtime.sigpanic called from 0x7f0de5040fb0 stack: frame={sp:0xc420041dc0, fp:0xc420041e10} stack=[0xc420040000,0xc420042000) 000000c420041cc0: 000000000044a012 <runtime.writeErr+66> 0000000000000002 000000c420041cd0: 00000000004e4207 0000000000000001 000000c420041ce0: 0000000000000001 000000c420041d20 000000c420041cf0: 0000000000427758 <runtime.gwrite+280> 00000000004e4207 000000c420041d00: 0000000000000001 0000000000000001 000000c420041d10: 0000000000427758 <runtime.gwrite+280> 00000000004e4521 000000c420041d20: 000000c420041d70 0000000000427f0d <runtime.printstring+125> 000000c420041d30: 00000000004e4207 0000000000000001 000000c420041d40: 0000000000000001 00000000004e4207 000000c420041d50: 0000000000000001 00000000004e4207 000000c420041d60: 0000000000426bca <runtime.dopanic+74> 000000c420041d70 000000c420041d70: 000000000044baa0 <runtime.dopanic.func1+0> 000000c420000180 000000c420041d80: 0000000000426ca1 <runtime.throw+129> 000000c420041da0 000000c420041d90: 000000c420041db0 0000000000426ca1 <runtime.throw+129> 000000c420041da0: 0000000000000000 0000000000000005 000000c420041db0: 000000c420041e00 00000000004392f1 <runtime.sigpanic+529> 000000c420041dc0: <00000000004e4521 0000000000000005 000000c420041dd0: 000000c420041e10 00000000004a864c <github.com/Bo0km4n/lupus.patchValue+124> 000000c420041de0: 00007f0de5040fb0 000000c420000180 000000c420041df0: 0000000000001000 0000000000001000 000000c420041e00: 000000c420041e78 !00007f0de5040fb0 000000c420041e10: >00007f0de5061091 000000c420041e50 000000c420041e20: 0000000000000001 0000000000000001 000000c420041e30: 0000000000000013 00007f0de5061000 000000c420041e40: 0000000000001000 000000c420041e50 000000c420041e50: 00007f0de5074a20 00007f0de50b0440 000000c420041e60: 000000c420041e50 0000000000000001 000000c420041e70: 0000000000000001 000000c420041f78 000000c420041e80: 00000000004a9054 <main.main+356> 0000000000000000 000000c420041e90: 0000000000000047 00000000004e59cf 000000c420041ea0: 000000000000000c 00000000004bc0e0 000000c420041eb0: 00000000004ec218 0000000000000000 000000c420041ec0: 0000000000000000 000000c420041ef8 000000c420041ed0: 00000000004ec218 0000000000000010 000000c420041ee0: 00000000004c9040 00000000004ec218 000000c420041ef0: 000000c42000e320 0000000000000000 000000c420041f00: 0000000000000000 00000000004c9040 runtime.sigpanic() /usr/lib/go-1.10/src/runtime/signal_unix.go:395 +0x211 fp=0xc420041e10 sp=0xc420041dc0 pc=0x4392f1
見事にsegvりました。
結論から言うと今回の手法では 新しい関数内で関数を呼び出していた場合は無理 ということです。正確には無理ではないかもしれません
まず、アップデート後に呼び出すnewFuncのアセンブラを見てみます。
00000000004a9140 <main.newFunc>: 4a9140: 64 48 8b 0c 25 f8 ff mov %fs:0xfffffffffffffff8,%rcx 4a9147: ff ff 4a9149: 48 3b 61 10 cmp 0x10(%rcx),%rsp 4a914d: 0f 86 91 00 00 00 jbe 4a91e4 <main.newFunc+0xa4> 4a9153: 48 83 ec 68 sub $0x68,%rsp 4a9157: 48 89 6c 24 60 mov %rbp,0x60(%rsp) 4a915c: 48 8d 6c 24 60 lea 0x60(%rsp),%rbp 4a9161: 48 c7 44 24 70 00 00 movq $0x0,0x70(%rsp) 4a9168: 00 00 4a916a: 0f 57 c0 xorps %xmm0,%xmm0 4a916d: 0f 11 44 24 38 movups %xmm0,0x38(%rsp) 4a9172: 48 8d 44 24 38 lea 0x38(%rsp),%rax 4a9177: 48 89 44 24 30 mov %rax,0x30(%rsp) 4a917c: 84 00 test %al,(%rax) 4a917e: 48 8d 05 db 39 01 00 lea 0x139db(%rip),%rax # 4bcb60 <type.*+0x12b60> 4a9185: 48 89 44 24 38 mov %rax,0x38(%rsp) 4a918a: 48 8d 05 ef f3 04 00 lea 0x4f3ef(%rip),%rax # 4f8580 <main.statictmp_1> 4a9191: 48 89 44 24 40 mov %rax,0x40(%rsp) 4a9196: 48 8b 44 24 30 mov 0x30(%rsp),%rax 4a919b: 84 00 test %al,(%rax) 4a919d: eb 00 jmp 4a919f <main.newFunc+0x5f> 4a919f: 48 89 44 24 48 mov %rax,0x48(%rsp) 4a91a4: 48 c7 44 24 50 01 00 movq $0x1,0x50(%rsp) 4a91ab: 00 00 4a91ad: 48 c7 44 24 58 01 00 movq $0x1,0x58(%rsp) 4a91b4: 00 00 4a91b6: 48 89 04 24 mov %rax,(%rsp) 4a91ba: 48 c7 44 24 08 01 00 movq $0x1,0x8(%rsp) 4a91c1: 00 00 4a91c3: 48 c7 44 24 10 01 00 movq $0x1,0x10(%rsp) 4a91ca: 00 00 4a91cc: e8 1f ff fd ff callq 4890f0 <fmt.Println> 4a91d1: 48 c7 44 24 70 02 00 movq $0x2,0x70(%rsp) 4a91d8: 00 00 4a91da: 48 8b 6c 24 60 mov 0x60(%rsp),%rbp 4a91df: 48 83 c4 68 add $0x68,%rsp 4a91e3: c3 retq 4a91e4: e8 27 3d fa ff callq 44cf10 <runtime.morestack_noctxt> 4a91e9: e9 52 ff ff ff jmpq 4a9140 <main.newFunc> 4a91ee: cc int3 4a91ef: cc int3
先程見せたf1のアセンブラとはめちゃくちゃ違います。 ただfmt.Printlnを追加しただけなのに。
完全な憶測ですが、goのコンパイラ側がスタックの調整やGC関連の命令を埋め込んでいるのではないかと思います。
ただ埋め込むだけなのは良いですが、問題は jbe 4a91e4 <main.newFunc+0xa4>
こんな感じの命令が散見されることです。
これらの命令は相対アドレスを参照しています。ここまで書けば あっ...(察し)ってなるでしょう。
当然先ほどの手法では上記のアセンブラをそのままヒープ領域に確保して書き込み実行します。
つまり jbe 4a91e4 <main.newFunc+0xa4>
のようなアドレスをそのまま踏んでセグフォってしまうのです。
流石にこの辻褄あわせを行う気力もどうやれば上手くやれるかも思いつきませんでした。
まとめ
返す値をちょろっと変えるだけならパッチを当てることができましたが、関数呼び出しを加えるのは難しいことがわかりました。
今回の記事は研究の一貫で調べていたものが検証していく中でかなり難しいことだとわかり、一旦保留になったので供養の意味も込めて書かせていただきました。 動的アップデートはそこまでホットな研究テーマではありませんが低レイヤなシステムプログラミングやソフトウェア工学等の観点から議論されている面白いテーマでもあります。 よかったら調べてみてください。
今回の実験したコードは下記のリポジトリにあります。 そのうちREADMEに実行のしかたとか書き加えると思います。 github.com