SWEet

A Software Engineer Is Eating Technologies

Goのプログラムで動的アップデートについてのトリビア

この記事は 

Go3 Advent Calendar 2018 - Qiita の21日目の記事にあたります。

アドベントカレンダーに参加するのは初めてのため、作法なので間違っているところがあったらすいません。

動的アップデート

動的アップデートという言葉をご存知でしょうか?ホットリロードだったりモンキーパッチだったり似たような意味で色々言い方があるかもしれませんがACMIEEEの論文を眺めているとDSU(Dynamic Software Update)と書いてることが多いです。

何をするかというと プログラムを中断させずにプログラムのアップデートを行う ことです。

具体的にはプロセスのメモリ空間を外部から書き換えて挙動を書き換えます。linuxにはlivepatchというツールがありますが、これはプロセスを指定し、アドレスを指定するとそのアドレスの値を書き換えることができます。要は黒魔術の一種です。

今回はGoで書かれたプログラムをGoでパッチ(厳密には違います)を書いて動的にプログラムの挙動を書き換える ということができるのかというのを検証してみます。

一般的な動的アップデートの手法

よく知られるDSUの手法を図に示します。

f:id:kk_river108:20181222003712p:plain

よくあるのが関数呼び出しで本来とは別の関数を呼び出すものです。

livepatchでは関数を呼び出したあと、直後のメモリ上の命令をjmp命令に書き換えてアップデート対象の関数アドレスに飛びます。 今回はこれを行います。

今回やってみるアップデートのフロー

  1. 自身を動的に書き換えるgoのプログラムを書く(アップデート対象のプログラム: target)
  2. 新しい関数を定義しただけのプログラムを書いてコンパイルする(パッチ: patch)
  3. targetはpatchのバイナリからアップデート対象となる関数のバイナリを抽出し、自身で呼び出している関数を書き変える

前提条件

  • 今回の記事における実験は全てgoコンパイラの最適化フラグはオフにしています。
  • os: ubuntu
  • go: 1.11
  • 関数の型はアップデート前とアップデート後で一致している

実装

以上の手順を行うコードを見ていきます。

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

参考文献

postd.cc

www.amazon.co.jp