🐣

脆弱性から学ぶRubyの仕組み(CVE-2022-28739編)

2022/12/06に公開

脆弱性から学ぶRubyの仕組み(CVE-2022-28739編)

最近業務でRubyを使うようになったのだが、これまで業務で使ったことのない言語だったので日々言語仕様などを勉強している。
加えて、業務上の別件でセキュリティ周りも勉強しておきたく、良い機会なので「脆弱性の側面からRubyの仕組みについて学ぶ」という暴挙に出てみようと思う。

Ruby

言わずとしれたプログラミング言語の一種。日本発の言語。
Rubyってなんぞ。石か?」という方はWikipedia参照。

脆弱性

コンピュータ上のバグや仕様の不具合から生じる、セキュリティ上の欠陥の総称。
予期せぬ挙動のうち、セキュリティ的によろしくない挙動を起こすものとも言える?
いろんな書籍やWebサイトでいろんな説明がされているため、抽象的なニュアンスはなんとなくわかるような気がするが、どのように言語化すればより正確な説明になるのか未だにわからない。
Wikipediaはこちら。

CVE-2022-28739

今回、調査対象とする脆弱性。
公式の記載はこんな感じ。

String を Float に変換する内部関数のバグにより、Kernel#FloatString#to_fなどの一部の変換メソッドでバッファのオーバーリードが発生する可能性があります。 典型的な結果はセグメンテーションフォールトによるプロセス終了ですが、限られた状況下では、不正なメモリ読み出しに悪用される可能性があります。

ざっくり言うと、String → Float に変換するときメモリのオーバーリードが発生しうる脆弱性。(そのまま)
NISTの記載から見るに、CVSSのスコアは7.5と比較的高め?(10がMaxのようなのでまあまあ高そう)
普通にセグフォで終わるケースが多いとのことだが、一部メモリ読み出しができてしまうケースがあるらしい。(めちゃくちゃ気になる)

既にパッチは当てられていて、2.6.10/2.7.6/3.0.4/3.1.2以降のバージョンを使っていれば大丈夫。
逆に、これら以前のバージョン(2.7.5とか3.1.1とか)を使っている場合はこの脆弱性を含んでいるということになる。

パッチを覗いてみる

実際に当てられているパッチの内容を見てみる。
大きく分けると、本体のコードには加えられている修正は2つ。

一つ目の変更。ポインタの指す先が無効な値の場合、後処理にジャンプする分岐を追加。

            if (!*++s || !(s1 = strchr(hexdigit, *s))) goto ret0;
            if (*s == '0') {
                while (*++s == '0');
+               if (!*s) goto ret;
                s1 = strchr(hexdigit, *s);
            }
            if (s1 != NULL) {

2つ目の変更。ポインタが有効である場合、繰り返しを続ける条件を追加。

                for (; *s && (s1 = strchr(hexdigit, *s)); ++s) {
                    adj += aadj * ((s1 - hexdigit) & 15);
                    if ((aadj /= 16) == 0.0) {
-                       while (strchr(hexdigit, *++s));
+                       while (*++s && strchr(hexdigit, *s));
                        break;
                    }
                }

脆弱性にはまだそこまで詳しくないが、入力値によっては何かが起きそうな香り。

試してみる

とりあえずやってみたほうが早いので試してみる。

事前準備

まずは、試してみるための環境を用意する。

# CRubyをクローンしてくる
$ git clone https://github.com/ruby/ruby.git

# パッチが適用されていない3.1.1に切り替える
$ git checkout tags/v3_1_1

### 👇ここからビルドしていく(依存関係は用意されている前提) ###

# configureを生成
$ ./autogen.sh

# ビルド用 & インストール用のディレクトリを作成
$ mkdir build && mkdir rubies

# ビルド用のディレクトリに移動、インストール先を指定してconfigureを実行
$ cd build
$ ../configure --prefix=path/to/ruby/rubies/v3_1_1

# 作成されたMakefileを使ってビルド
$ make install

# インストール用のディレクトリに色々作られてる (例えばbin)
$ cd ../rubies/v3_1_1/bin/
$ ls
bundle  bundler  erb  gem  irb  racc  rake  rbs  rdbg  rdoc  ri  ruby  typeprof

# CRuby 3.1.1 のできあがり
$ ./ruby --version
ruby 3.1.1p18 (2022-02-18 revision 53f5fc4236) [x86_64-linux]

全然関係ないがminirubyっていうのも吐き出されてて「そんなのもあるのか・・・」と思った。

実行してみる

パッチに含まれているテストコードを見ながら、試しに怪しいスクリプトを実行してみる。

適当に/tmpとかにこんな感じのスクリプトを作って、

2000.times do
  f = Float('0x' + ('0' * 30))
end

ビルドした3.1.1で実行。

$ ./ruby --version
ruby 3.1.1p18 (2022-02-18 revision 53f5fc4236) [x86_64-linux]

$ ./ruby /tmp/test.rb
<internal:kernel>:173:in `Float': invalid value for Float(): "0x000000000000000000000000000000" (ArgumentError)
	from /tmp/test.rb:9:in `block in invalid_value'
	from /tmp/test.rb:8:in `times'
	from /tmp/test.rb:8:in `invalid_value'
	from /tmp/test.rb:17:in `<main>'

たしかになんか落ちた。
パッチ適用済みの3.1.2で実行してみても問題なく動くのがわかる。

$ ruby --version
ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [x86_64-linux]

$ ruby /tmp/test.rb

2000回ループしてるところだが、試しに単発で試してみると何事も起きずに正常終了することもあるみたいで、2000回くらいループすれば確実に異常ケース踏むみたい。

デバッグしてみる

実際に何かが起きることが確認できたところで、パッチがあたっている処理ををもう少し深く見てみる。

該当のパッチがあたっているのはstrtodという関数。

double
strtod(const char *s00, char **se)
{
    ...
        switch (*s) {
          ...  // 符号を判定したり、スペースをスキップしているところなので省略
          default:
            goto break2;
        }
break2:
    if (*s == '0') {
        if (s[1] == 'x' || s[1] == 'X') {
            ...

            if (!*++s || !(s1 = strchr(hexdigit, *s))) goto ret0;
            if (*s == '0') {
                while (*++s == '0');
                if (!*s) goto ret;  // ここがパッチのところ①
                s1 = strchr(hexdigit, *s);
            }
    ...

前半部で「符号の有り無しを判定」したり「スペースやらをスキップ」したりしている。
その後、0x0Xで始まる場合、対象のパッチがあたっている箇所に入っていく。
0x0Xの直後から'0'(0x30)が続く間ポインタを進めて、それ以外の文字以外に到達したら16進数文字をstrchrで探している。

もう一箇所は小数点を含む場合。

    ...
        if (*s == '0') {
            while (*++s == '0');
            if (!*s) goto ret;  // ここがパッチのところ①
            s1 = strchr(hexdigit, *s);
        }

        ...

        if (*s == '.') {
            dsign = 1;
            if (!*++s || !(s1 = strchr(hexdigit, *s))) goto ret0;
            if (nd0 < 0) {
                while (*s == '0') {
                    s++;
                    nd0 -= 4;
                }
            }
            for (; *s && (s1 = strchr(hexdigit, *s)); ++s) {
                adj += aadj * ((s1 - hexdigit) & 15);
                if ((aadj /= 16) == 0.0) {
                    while (*++s && strchr(hexdigit, *s));  // ここがパッチのところ②
                    break;
                }
    ...

なんか色々やっているみたいだが、実際に動かしながら見たほうがわかりやすそう。
なので、gdbみたいなのを使って動かしながらデバッグしてみる。
(ついでにRubyの仕組みについても勉強にならないと企画倒れになってしまう)

gdbでやっていく

デバッグ用にビルドして、さっきのスクリプトを実行してみる。

# ビルド用のディレクトリに移動
$ cd path/to/ruby/build

# デバッグ用にconfigure
$ ../configure optflags="-O0" --prefix="path/to/ruby/rubies/v3_1_1"

# ビルド
$ make install

やっていく。

$ gdb -q --args ./ruby /tmp/test.rb
Reading symbols from ./ruby...

# とりあえず怪しそうなところ(`missing/dtoa.c`の`1553`行目)にブレークポイントをおいてみる
(gdb) break missing/dtoa.c:1553
Breakpoint 1 at 0x2024ba: file ../missing/dtoa.c, line 1553.

# ブレークポイントまで進める (ruby_strtodで止まっているのがわかる)
(gdb) run
Starting program: /home/kate/src/ruby/rubies/v3_1_1/bin/ruby /tmp/test.rb
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, ruby_strtod (s00=0x7ffff3717038 "0x000000", se=0x7fffffffd3c8) at ../missing/dtoa.c:1553
1553		    if (!*++s || !(s1 = strchr(hexdigit, *s))) goto ret0;

# backtraceでここまでの道のりを出力してみる
(gdb) backtrace
#0  ruby_strtod (s00=0x7ffff3717038 "0x000000", se=0x7fffffffd3c8) at ../missing/dtoa.c:1553
... (たくさん出てくる)
#25 0x000055555558399f in main (argc=<optimized out>, argv=<optimized out>) at ../main.c:47

backtraceの出力を辿ってみると、こんな感じで遥々やってきているらしい。

  • main (<= Rubyプロセスのエントリポイント)
  • ruby_run_node
  • rb_ec_exec_node
  • rb_vm_exec
  • ... (たぶん2000.timesとか呼んでる箇所で本筋とは関係ないので省略)
  • vm_exec_core
  • vm_invoke_builtin_delegate
  • invoke_bf
  • rb_f_float1
  • rb_convert_to_float
  • rb_str_to_dbl
  • rb_str_to_dbl_raise
  • rb_cstr_to_dbl_raise
  • ruby_strtod (<= ここがパッチが当たっている関数)

呼び出し経路を軽く把握する

Rubyの仕組みを知るため、ここまで呼ばれている関数を軽く理解してみる。

main

言わずとしれたエントリポイント。シェル上でrubyとか実行すると、とりあえずここから実行が始まる。
ruby/main.c:34にある。(3.1.1の場合)

...

static int
rb_main(int argc, char **argv)
{
    RUBY_INIT_STACK;
    ruby_init();
    return ruby_run_node(ruby_options(argc, argv));  // <= 次はここ
}

// Wasm系の書き分けがあって気になる
#if defined(__wasm__) && !defined(__EMSCRIPTEN__)
int rb_wasm_rt_start(int (main)(int argc, char **argv), int argc, char **argv);
#define rb_main(argc, argv) rb_wasm_rt_start(rb_main, argc, argv)
#endif

int
main(int argc, char **argv)
{
#ifdef RUBY_DEBUG_ENV
    ruby_set_debug_option(getenv("RUBY_DEBUG"));
#endif
#ifdef HAVE_LOCALE_H
    setlocale(LC_CTYPE, "");
#endif

    ruby_sysinit(&argc, &argv);
    return rb_main(argc, argv);
}

ruby_run_node

ここからどんどんRuby本体の処理に潜っていく。

公式リポジトリに含まれる説明はこれ。

Runs the given compiled source and exits this process. It returns EXIT_SUCCESS if successfully runs the source. Otherwise, it returns other value.

compiled sourceと書かれているので、既に構文木からバイトコードに変換済みのもの?を実行する。
関数自体はは全然薄くてこんな感じ。ruby/eval.c:312にある。(3.1.1の場合)

int
ruby_run_node(void *n)
{
    rb_execution_context_t *ec = GET_EC();
    int status;
    if (!ruby_executable_node(n, &status)) {
        rb_ec_cleanup(ec, (NIL_P(ec->errinfo) ? TAG_NONE : TAG_RAISE));
        return status;
    }
    ruby_init_stack((void *)&status);
    return rb_ec_cleanup(ec, rb_ec_exec_node(ec, n));
}

実行コンテキスト?の取得、スタックの初期化とかをやっているように見える。

rb_ec_exec_node

ruby/eval.c:271にある。(3.1.1の場合)
渡された命令列(iseq)をVMの実行に引き渡している。こちらも関数的に全然大きくなくこんな感じ。
iseqInstruction Sequenceのこと。

static int
rb_ec_exec_node(rb_execution_context_t *ec, void *n)
{
    volatile int state;
    rb_iseq_t *iseq = (rb_iseq_t *)n;
    if (!n) return 0;

    EC_PUSH_TAG(ec);
    if ((state = EC_EXEC_TAG()) == TAG_NONE) {
        rb_thread_t *const th = rb_ec_thread_ptr(ec);
        SAVE_ROOT_JMPBUF(th, {
            rb_iseq_eval_main(iseq);
        });
    }
    EC_POP_TAG();
    return state;
}

PUSHとかEXECとかPOPとかしてるTAGってなんだろ。

rb_vm_exec

この名前に直接該当する関数が見当たらない。マクロとかで変換されてる?(C言語力が足りてないか探しきれてない)

vm_exec_core

ruby/vm_exec.c:172に書かれている。(3.1.1の場合)
ここでRubyオブジェクトを指すポインタであるVALUE型が出てきている。
この辺からRubyバイトコードもRubyオブジェクトとして扱えるようになってる?

static VALUE
vm_exec_core(rb_execution_context_t *ec, VALUE initial)
{
    register rb_control_frame_t *reg_cfp = ec->cfp;
    rb_thread_t *th;

    while (1) {
	reg_cfp = ((rb_insn_func_t) (*GET_PC()))(ec, reg_cfp);

	if (UNLIKELY(reg_cfp == 0)) {
	    break;
	}
    }

    if ((th = rb_ec_thread_ptr(ec))->retval != Qundef) {
	VALUE ret = th->retval;
	th->retval = Qundef;
	return ret;
    }
    else {
	VALUE err = ec->errinfo;
	ec->errinfo = Qnil;
	return err;
    }
}

vm_invoke_builtin_delegate

ruby/vm_insnhelper.c:5920にある。(3.1.1の場合)
引数の数によって組み込み関数を呼び分けているように見える。rb_builtin_funcntion型のポインタが組み込み関数への関数ポインタか?

static VALUE
vm_invoke_builtin_delegate(rb_execution_context_t *ec, rb_control_frame_t *cfp, const struct rb_builtin_function *bf, unsigned int start_index)
{
    if (0) { // debug print
        fputs("vm_invoke_builtin_delegate: passing -> ", stderr);
        for (int i=0; i<bf->argc; i++) {
            ruby_debug_printf(":%s ", rb_id2name(cfp->iseq->body->local_table[i+start_index]));
        }
        ruby_debug_printf("\n" "%s %s(%d):%p\n", RUBY_FUNCTION_NAME_STRING, bf->name, bf->argc, bf->func_ptr);
    }

    if (bf->argc == 0) {
        return invoke_bf(ec, cfp, bf, NULL);
    }
    else {
        const VALUE *argv = cfp->ep - cfp->iseq->body->local_table_size - VM_ENV_DATA_SIZE + 1 + start_index;
        return invoke_bf(ec, cfp, bf, argv);
    }
}

invoke_bf

ruby/vm_insnhelper.c:5904にある。(3.1.1の場合)
lookup_builtin_invokerで組み込みの何かしらを検索して呼び出してる。bfbuiltin functionの略っぽい。
SETUP_CANARYとかCHECK_CANARYで色々やっているcanaryは、スタックオーバーフロー対策のような雰囲気ある。

static inline VALUE
invoke_bf(rb_execution_context_t *ec, rb_control_frame_t *reg_cfp, const struct rb_builtin_function* bf, const VALUE *argv)
{
    const bool canary_p = ISEQ_BODY(reg_cfp->iseq)->builtin_inline_p; // Verify an assumption of `Primitive.attr! 'inline'`
    SETUP_CANARY(canary_p);
    VALUE ret = (*lookup_builtin_invoker(bf->argc))(ec, reg_cfp->self, argv, (rb_insn_func_t)bf->func_ptr);
    CHECK_CANARY(canary_p, BIN(invokebuiltin));
    return ret;
}

rb_f_float1

この辺りから変換処理っぽい雰囲気が醸し出されてくる。

最初に呼ばれるrb_f_float_1はこんな感じ。場所は、ruby/object.c:3534

static VALUE
rb_f_float1(rb_execution_context_t *ec, VALUE obj, VALUE arg)
{
    return rb_convert_to_float(arg, TRUE);
}

組み込み関数のインターフェースとしてまずこの関数が呼ばれ、そこから実際の変換処理に渡している。
VALUE型の引数には、レシーバーとなるRubyオブジェクトへのポインタが入っているみたい。

rb_convert_to_float

次に呼ばれるrb_convert_to_floatはこんな感じ。ruby/object.c:3498にある。

static VALUE
rb_convert_to_float(VALUE val, int raise_exception)
{
    switch (to_float(&val, raise_exception)) {
      case T_FLOAT:
        return val;
      case T_STRING:
        if (!raise_exception) {
            int e = 0;
            double x = rb_str_to_dbl_raise(val, TRUE, raise_exception, &e);
            return e ? Qnil : DBL2NUM(x);
        }
        return DBL2NUM(rb_str_to_dbl(val, TRUE));
      case T_NONE:
        if (SPECIAL_CONST_P(val) && !raise_exception)
            return Qnil;
    }

    if (!raise_exception) {
        int state;
        VALUE result = rb_protect(convert_type_to_float_protected, val, &state);
        if (state) rb_set_errinfo(Qnil);
        return result;
    }

    return rb_convert_type_with_id(val, T_FLOAT, "Float", id_to_f);
}

呼び出し元のオブジェクトによって、変換処理を呼び分けているのがわかる。

  • T_FLOAT(浮動小数点数)の場合
    ➞ そのまま返す
  • T_STRING(文字列)の場合
    rb_str_to_dbl_raiseを呼び出す
  • T_NONE(nil)の場合
    nilを返す

第1引数に変換対象のRubyオブジェクトへの参照、第2引数には何かあったら例外を投げるかどうかのフラグが指定されているみたい。

rb_str_to_dbl_raise

更に次に呼ばれるのはrb_str_to_dbl_raiseruby/object.c:3377にある。

static double
rb_str_to_dbl_raise(VALUE str, int badcheck, int raise, int *error)
{
    char *s;
    long len;
    double ret;
    VALUE v = 0;

    StringValue(str);
    s = RSTRING_PTR(str);
    len = RSTRING_LEN(str);
    if (s) {
        if (badcheck && memchr(s, '\0', len)) {
            if (raise)
                rb_raise(rb_eArgError, "string for Float contains null byte");
            else {
                if (error) *error = 1;
                return 0.0;
            }
        }
        if (s[len]) {		/* no sentinel somehow */
            char *p = ALLOCV(v, (size_t)len + 1);
            MEMCPY(p, s, char, len);
            p[len] = '\0';
            s = p;
        }
    }
    ret = rb_cstr_to_dbl_raise(s, badcheck, raise, error);
    if (v)
        ALLOCV_END(v);
    return ret;
}

分岐に入る前に、Rubyオブジェクトとして文字列を扱っているRStringから「生の文字列へのポインタ」「文字列の長さ」を取り出して、次のrb_cstr_to_dbl_raiseに渡している。

rb_cstr_to_dbl_raise

またさらに次に呼ばれるrb_cstr_to_dbl_raise。これはちょっと長いので重要そうな部分だけ抜粋。場所はruby/object.c:3255

static double
rb_cstr_to_dbl_raise(const char *p, int badcheck, int raise, int *error)
{
    ...

    d = strtod(p, &end);
    ...
    if (*end) {
        ...

        while (p < end && n < e) prev = *n++ = *p++;
        while (*p) {
            if (*p == '_') {
                ...
            }
            prev = *p++;
            if (e == init_e && (prev == 'e' || prev == 'E' || prev == 'p' || prev == 'P')) {
                ...
            }
            else if (ISSPACE(prev)) {
                ...
            }
            else if (prev == '.' ? dot_seen++ : !ISDIGIT(prev)) {
                if (badcheck) goto bad;
                break;
            }
            if (n < e) *n++ = prev;
        }
    ...
  bad:
    if (raise) {
        rb_invalid_str(q, "Float()");
        UNREACHABLE_RETURN(nan(""));
    }
    else {
        if (error) *error = 1;
        return 0.0;
    }
}

badラベルの箇所を見ると、ここに来た場合rb_invalid_strとして例外が発生させるみたい。

rb_strtod

先の関数の中でついに呼ばれるのが、今回パッチが当たっていたrb_strtod
ここまでの処理を見るに、Rubyオブジェクトから取り出した生の文字列をdouble型に変換するユーティリティ的な関数みたい。
実際、ruby/util.c:613という明らかにユーティリティをまとめたような名前のファイルで、strtodからruby_strtodに置き換えている記載が見て取れる。

missing/dtoa.c:36にこんな記載がある。

 * This strtod returns a nearest machine number to the input decimal
 * string (or sets errno to ERANGE).  With IEEE arithmetic, ties are
 * broken by the IEEE round-even rule.  Otherwise ties are broken by
 * biased rounding (add half and chop).

なにやら入力された文字列から、最適なマシンのアーキテクチャに対応する番号を返すらしい。(脳死)
1つ目の引数で対象の文字列、2つ目の引数に終端を呼び出し元に伝えるためのポインタが渡される。
この関数は後々重要な部分をかいつまんでいくため、コードは割愛。

メモリの中を見てみる

ざっくりとパッチが当たっている関数までの道のりについて把握した所で、実際にデバッグをやっていく。

まずは、strtodが呼び出される前後で止めて引数がどうなっているか見てみる。
呼び出してるのはobject.cのここ。

3274 |    }
3275 |
3276 |    d = strtod(p, &end);  // <= ここで呼び出してる
3277 |    if (errno == ERANGE) {
3288 |        OutOfRange();

ブレークポイントを設定して動かしていく。

# ブレークポイントを設定
(gdb) break object.c:3275
Breakpoint 1 at 0xf4865: file ../object.c, line 3276.
(gdb) break object.c:3277
Breakpoint 2 at 0xf487b: file ../object.c, line 3277.

# プロセス起動 (strtodを呼び出す直前で止まる)
(gdb) run
Starting program: path/to/ruby/rubies/v3_1_1/bin/ruby /tmp/test.rb
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, rb_cstr_to_dbl_raise (p=p@entry=0x555555bf8470 "0x", '0' <repeats 30 times>, badcheck=badcheck@entry=1, raise=raise@entry=1, error=error@entry=0x0) at ../object.c:3276
3276	    d = strtod(p, &end);

# pとendを出力
(gdb) p p
$1 = 0x555555bf8470 "0x", '0' <repeats 30 times>
(gdb) p end
$2 = 0x5555556f90da <rb_st_update+570> "\205\300\017\204~"

# strtodを呼び出した直後まで進める
(gdb) c
Continuing.

Breakpoint 2, rb_cstr_to_dbl_raise (p=p@entry=0x555555bf8470 "0x", '0' <repeats 30 times>, badcheck=badcheck@entry=1, raise=raise@entry=1, error=error@entry=0x0) at ../object.c:3277
3277	    if (errno == ERANGE) {

# pとendを出力
(gdb) p p
$3 = 0x555555bf8470 "0x", '0' <repeats 30 times>
(gdb) p end
$4 = 0x555555bf8492 "tion.f!"

ん?endが知らない子を指してないか...?明らかになんかの文字列っぽい。

さらにこのまま進めると例外で落ちる。(本来0.0が返ってきてほしい)

(gdb) c
Continuing.
<internal:kernel>:173:in `Float': invalid value for Float(): "0x000000000000000000000000000000" (ArgumentError)
	from /tmp/test.rb:9:in `block in invalid_value'
	from /tmp/test.rb:8:in `times'
	from /tmp/test.rb:8:in `invalid_value'
	from /tmp/test.rb:15:in `<main>'
[Inferior 1 (process 22960) exited with code 01]

tion.f!ってなんぞ。
今は30文字で動かしているけど、もしかしたら文字数減らすともっと前の文字も見えるかも?と思ってやってみたら見えた。(22文字に減らした)

(gdb) p end
$160 = 0x555555c3e13b "ification.fin!"

ちなみに20文字にすると空文字が入っていた。
21文字にするとこんな感じでよくわからんのが入っている。

(gdb) p end
$161 = 0x7ffff3716e70 "\005@U"

さらに、何回かに一回一発で落ちないパターンの時もある。

# 1回目のループ
(gdb) p end
$51 = 0x555555c34161 ""

# 2回目
(gdb) p end
$52 = 0x555555b81ce1 "\r"

# 3回目
(gdb) p end
$53 = 0x555555c7c491 ""

# 4回目 (変なところ指してる)
(gdb) p end
$54 = 0x555555c655d1 "\250\307UUU"

# 落ちる
(gdb) c
Continuing.
<internal:kernel>:173:in `Float': invalid value for Float(): "0x000000000000000000000000000000" (ArgumentError)
	from /tmp/test.rb:9:in `block in invalid_value'
	from /tmp/test.rb:8:in `times'
	from /tmp/test.rb:8:in `invalid_value'
	from /tmp/test.rb:15:in `<main>'
[Inferior 1 (process 23274) exited with code 01]

指しているアドレスについては、メモリマップを見てみると間違いなくRubyプロセスのヒープ上であることがわかる。
(Linuxで作業しているので、/proc/{PID}/mapsを見る)

...
55555599f000-555555ca2000 rw-p 00000000 00:00 0                          [heap]
...

前住んでた住人の名残...?

なんだこれ...と思ってたら、"\250\307UUU"末尾のUUUの部分がちょっと見たことある気がしてきた。

筆者Emacsを使っているのだが、Emacsのモードラインの左端に文字コードを表す記号としてUUUとか表示されることがある。
もしかして、そのモードラインの左端に表示される文字列がここに書き込まれてた名残...?
ification.fin!もどこかで使われてた文字列の一部かも...

何が起きているのか

しばらく眺めていたら、なんとなく何が起きているのかわかってきた。
ポインタが意図せぬ場所を指している背景としてはこんな感じみたい。

① ポインタが文字列の終端まで到達する
まずは、文字'0'(0x30)が続いている間スキップしているところ。
'0'(0x30)しか含まれない場合、終端のNULL文字(0x00)までポインタ*sが進んで止まる。
この直後で、16進数の文字が列挙されている文字列hexdigitから*sを探索した時に、終端のNULL文字に引っかかりs1にはhexdigitの終端を指すポインタが代入されているみたい。

    if (*s == '0') {
		while (*++s == '0');  // ここで'0(0x30)'の間進む
		s1 = strchr(hexdigit, *s);
    }

この時点で変数ss1の状況はこんな感じ。

s  => 変換対象の文字列の終端を指すポインタ
s1 => `hexdigit`の終端を指すポインタ

② 変換対象の文字列を指すsの終端から先にポインタが進む
その後以下のような処理が来るが、s1にはhexdigitの終端を指すポインタが代入されているのでNULLとならずループに突入。
do-whileによってsが終端よりもさらに先まで進められる。(hexdigitにマッチする間進み続ける)

    if (s1 != NULL) {
		do {
		    adj += aadj * ((s1 - hexdigit) & 15);
		    nd0 += 4;
		    aadj /= 16;
		} while (*++s && (s1 = strchr(hexdigit, *s)));  // ここでどんどん進む
    }

この時点の変数ss1はこんな感じ。

s  => 変換対象の文字列に隣接するメモリ領域のどこか
s1 => `hexdigits`のどこか (隣接するメモリ領域の中で16進数の文字にたまたまマッチしたところ)

③ 予期せぬポインタが、呼び出し元に返される
その後の処理はいろいろあって、retラベルにジャンプ。(s, s1は変更されない)
seは呼び出し元から渡されているポインタで、ここに現時点のsを代入して呼び出し元(rb_csr_to_dbl_raise)に戻る。

ret:
    if (se)
        *se = (char *)s;
    return sign ? -dval(rv) : dval(rv);

④ 呼び出し元で例外が送出される
呼び出し元に戻ってきた後は以下の通り。

static double
rb_cstr_to_dbl_raise(const char *p, int badcheck, int raise, int *error)
{
    ...

    // p   => 変換対象の文字
    // end => 16進数文字列の終端を代入してもらうポインタ
    d = strtod(p, &end);  // ここでendにおかしなポインタが入ってくる
    ...

    if (*end) {  // 運悪く16進数文字列の外側のなにかを指している場合この分岐に入る
        ...

        if (*p == '0') {  // pの先頭は'0x'なので'x'までスキップされる
            prev = *n++ = '0';
            while (*++p == '0');
        }
        while (p < end && n < e) prev = *n++ = *p++;  // prevが16進数文字列の外側まで進む
        while (*p) {
            ...
            else if (prev == '.' ? dot_seen++ : !ISDIGIT(prev)) {
                // prevがドットでもなく、数字でもないのでここに入る
                if (badcheck) goto bad;  // badラベルにジャンプ
                break;
            }
    ...
  bad:
    if (raise) {
        // 例外が送出される
        rb_invalid_str(q, "Float()");
        UNREACHABLE_RETURN(nan(""));
    }
    else {
        if (error) *error = 1;
        return 0.0;
    }
}

こんな感じで、Rubyプロセス自体は例外で落ちるものの、メモリ上の読めない方が良さそうな領域まで読めてしまっているみたい。

ちょっと気になったこと

今回は単発のプロセス実行で部分的にオーバーリードしているが、こんなこともできたりしないかちょっと気になった。(別途実験してみても面白そう)

  • ある条件下で何百回もプロセス実行
  • プロセスが配置されるメモリ領域がちょくちょく変わる (意図的に変えれるのかはわからない)
  • チョコチョコといろんな領域をオーバーリードして、読み込んだメモリの断片を集める
  • 集めたメモリ断片を組み合わせれば、ある時点のメモリを部分的に復元できたり...?

(そもそもRubyプロセス自体が何度も色んな所に配置されるなら、そのプロセスによってメモリが上書きされちゃっている可能性も十分あり得るかもだが)

あと2000回くらいループさせれば確定で発生する件について、単純にヒープ上に確保される位置が悪くて偶然オーバーリードしなかっただけ?
短い文字列のときは発生しない件についても、Rubyが文字列をヒープ上に確保する際の処理が関係してたりするのか気になる。

最後に

脆弱性からRubyの仕組みを知るという暴挙に出てみて、脆弱性の理論を把握しながらでなかなか苦戦した部分もあったが、実際動かしながら仕組みがわかっていく様子はとても楽しめた。
今回はFloatへの変換部分を見ていったが、他の脆弱性からまた別の箇所の仕組みも見れると面白そうなのでやってみたい。
同じアプローチでRailsの仕組みを知るのも楽しそう。

GitHubで編集を提案

Discussion