RustはHeartbleedを防げたのか?

はじめに

ja.wikipedia.org

2014年に、「OpenSSL」にHeartbleedという脆弱性が見つかり、話題になっていました。 この脆弱性は、典型的なメモリ操作に関するバグであったため、Rustであれば発生しなかったのではないか?という観点で調べていると、過去に同じ主張をしている文献をいくつか発見しました。

こういうのは、後からなら何とでも言える部分があるのですが、やはりRustなら発生しなかっただろうな、というのが私の見解です。 Rustでは、メモリ操作に関連する脆弱性になり得るコードの多くが、コンパイル時にチェックされます。 スライスやコレクションについては、実行時に境界チェックが入るため、最悪サービスが停止しますが、データが流出したり、バグったまま動き続けるようなことはありません。

なるべく、安全なプログラミング言語を使って、開発を進められるようにしていきたいですね。

RustでならHeartbleedを防げたとする文献

Rust would’ve prevented Heartbleed.

tonyarcieri.com

The Heartbleed bug would not have been possible if OpenSSL had been implemented in Rust.

blog.getreu.net

Embedded System Security with Rust: Case Study of Heartbleed

エストニアのセキュリティ研究者の論文?レポート?が公開されています。 非常に丁寧に解説されているので、こちらを詳細に見ていきます。

Abstract

組込みでは、速度向上やメモリレイアウトを制御するため、C/C++が使われてきました。 しかし、今日、組込みシステムでは、セキュリティがより重要になってきています。

Unfortunately, C/C++ supports some secure software design principles only rudimentary.

多くの脆弱性が、C/C++がメモリ安全でないことに関連しています。 例として、Heartbleedを取り上げて、技術的な詳細とその影響を論じます。

Introduction

組込みデバイスがネットワークに接続するようになり、組込みデバイスのセキュリティが重要になってきています。 IoTによりコンシューマデバイスのあらゆるものがネットワーク接続されるようになります。

Causes

Schneirは、組込みデバイスがセキュアでない主な理由は、ソフトウェアにパッチがあてられておらず、最新のものよりはるかに古いせいだと述べています。 SoCや組込みデバイスの製造元がfirmwareのアップデートを用意しても、ユーザーがそれを適用するのは容易ではありません。

状況は変わったのでしょうか?製造元の観点ではセキュリティはコストのかかるサービスです。 コストとセキュリティによる利益のバランスを取ることは、いつも簡単ではありません。 しかし、日に日にそのリスクに気づくユーザーが増えています。 デバイスがハッキングされると、ブランドに傷がつきます。 Deutsche-Telecomや他の会社は、既にパッチポリシーや脆弱性報告の交付を改善しています。

消費者がセキュリティにコストをかけるには至っていません。 技術に精通していないユーザーは、エラーのないソフトウェアが存在していないことを理解できません。 メーカーは、ソフトウェアの不具合を隠す代わりに、バグフィックスのインフラ整備を進めるべきです。

Embedded System Development

組込みで最も無視されていた属性は、機密性です。ネットワークインタフェースを使った通信を行うようになり、徐々に重要性が増してきています。

組込みシステムをより安全にするために、何ができるのでしょうか? 設計レベルのアプローチとして、BPNM, Secure Tropos, Misuse Cases, Mal-activity diagrams, UMLsec, SecureUML, Trust Trade-off Analysisなどがあります。

プログラミング側では、多くの近代プログラミング言語がメモリ安全性やデータ競合のないセキュリティ機能を提供しています。 しかし、組込みシステムでは、53%がC++を、52%がCを使っています。

CもC++もメモリ安全性を保証しません。 CやC++を使った低レイヤプログラミングでは、メモリ破壊のバグが古くからコンピュータセキュリティの問題でした。 このような安全性を欠いた言語は、攻撃者にプログラムの動作を変更したり、制御フローをハイジャックすることにより完全に制御を奪うことを許します。

CWE/SANS Top 25 of 2011

による脆弱性のランキングのうち、C/C++のメモリ安全性バグに関連するものを、示す。

f:id:tomo-wait-for-it-yuki:20190215210234p:plain
CWE in C/C++ that affect memory

メモリに関連する問題は、Cプログラムが変数やオブジェクトのポインタをメモリ配置やライフタイムの外側から、操作することです。 Javaのようなメモリ安全な言語は、直接ポインタを操作できません。Javaコンパイラは実行時のリソースコストとガベージコレクタによって、メモリ安全を達成します。 この追加のコストがあるため、リアルタイムの組込みアプリケーションでは、このような解決策を使えません。

長い間、プログラムの効率とメモリ安全とは、相反するもののようでした。 しかし、Rustと呼ばれる新しいプログラミング言語は、データの所有権というセマンティクスを導入することで、うまく両者のバランスを取っています。 新しいプログラミングパラダイムでは、コンパイル時にメモリ安全性を保証します。 実行時のコストは必要ありません。表に示したCWEは、Rustではほとんどコンパイル時に検出されます。 Rustのメモリ安全性は、未定義動作を引き起こさないことと、メモリリークが発生ないことを保証します。

The Heartbleed Vulnerability

Heartbleedは、C/C++のメモリ安全性に関連する脆弱性の典型的な例です。関連するCWEは、CWE-126: Buffer Over-readです。

The Bug of the Century

まるでSFのようですが、たった4行の誤ったプログラムが、インターネット暗号化インフラの少なくとも4分の1を危険にさらしました。 当時、信頼された認証局から証明書が発行されているインターネット上のWebサーバの17.5%(約50万台)で、この脆弱性が存在するHeartbeat拡張が有効になっていました。 これらのサーバーの秘密鍵や利用者のセッション・クッキーやパスワードを盗み出すことが出来る可能性がありました。

このバグは、2012年1月にSSL/TLSプロトコルの拡張であるHeartbeat機能によってもたらされました。

The Heartbeat Protocol Extension

Heartbleedというバグの名前は、HeartbeatというTLS/DTLSのプロトコル拡張から名づけられています。 SSL/TLSクライアントがリモート接続が生きているかどうかをテストできるようにします。 クライアントは、TLS1_HB_REQUESTを使い捨ての文字列とともに送ります。サーバーは届いたデータをエコーバックします。 クライアント側は、エコーバックで届いたデータを、自身が送ったデータと比較し、一致すると接続が有効であると判断します。

TLS1_HB_REQUESTは、payloadと呼ばれるフィールドを持っています。 payloadは、payload文字列の長さを表しており、サーバーがエコーバックする時に、memcpy()で何バイトコピーするかを決めるために使います。

f:id:tomo-wait-for-it-yuki:20190216044313p:plain
TLS Heartbeat protocol

How Does the Heartbleed-Exploit Work?

Heartbleed脆弱性は、実装の問題です。境界チェックがないため、不適切な入力検証によって発生します。 Heartbeatプロトコルの設計には不具合がないことが重要です。

Heartbleedのexploitは非常にシンプルです。 TLS1_HB_REQUESTに、実際のpayloadより長いpayload-lengthを設定します。 すると、サーバー側の、OpenSSLメモリ内部の64KiBのデータがクライアントに送り返されます。

f:id:tomo-wait-for-it-yuki:20190216045002p:plain
TLS Heartbeat with spoofed payload-length-field

unsigned char *p = &s->s3->rrec.data[0], *pl;
unsigned short hbtype;
unsigned int payload;
unsigned int padding = 16;

hbtype = *p++;
n2s(p, payload);  // TLSリクエストから`payload-length`を読み込む。実際のpayloadの長さと一致しているかのチェックをしていない。
pl = p;


if (hbtype == TLS1_HB_REQUEST)
        {
        unsigned char *buffer, *bp;
        int r;

        buffer = OPENSSL_malloc(1 + 2 + payload + padding);
        bp = buffer;

        *bp++ = TLS1_HB_RESPONSE;
        s2n(payload, bp);
        memcpy(bp, pl, payload);  // `payload-length`バイト分、コピーする。Buffer over-read脆弱性!

        r = ssl3_write_bytes(s, TLS1_RT_HEARTBEAT, buffer,
                3 + payload + padding); 

The Heartbleed-Patch

2つのif文を追加することで、Heartbleedのバグを修正できます。

// TLSリクエストが短すぎる場合、リクエストを破棄して、返答しない 
if (1 + 2 + 16 > s->s3->rrec.length)   
    return 0;   
hbtype = *p++;
n2s(p, payload);
// TLSリクエストのpayload文字列が、payload-length-fieldより短い場合、リクエストを破棄して、返答しない
if (1 + 2 + payload + 16 > s->s3->rrec.length)  
    return 0;   
pl = p;

What Can We Learn?

Heartbleedは、単純なプログラミングエラーであり、悪意があったわけではありません。 しかし、将来の同種の脆弱性について考えると、Heartbleedが意図的に導入された脆弱性に分類されることに留意することが重要です。

f:id:tomo-wait-for-it-yuki:20190216051939p:plain
Development Concepts

Heartbleedは、意図的に導入される可能性があったわけです。 忘れてはいけないことは、検証の欠落はよくあるミスであることです。 さらに、コードから欠落している行を見つけることは、コード内の不具合を見つけるより、難しいです。 これはコードレビュワーにとっても同じです。 脆弱性のあるコードがレビュープロセスを通過することは、驚くことではありません。

C/C++では、memcpy(bp, pl, payload)メモリの秘密鍵や認証情報を含んでいるpaylodをコピーするというようなことを明示的に示してくれるセマンティクスがありません。 Rustは、プログラムに明示的に書いたことだけを行い、それ以外を行わないプログラミング言語なのです。

Could Heartbleed Have Happened With Rust?

下のプログラムは、Rustで、Cのコードを再現したものです。

fn tls1_process_heartbeat (s: Ssl) -> Result<(), isize> {
    const PADDING: usize = 16;

    let p = s.s3.rrec;
    let hbtype:u8 = p[0];
    // payload-lengthを`TLS1_HB_REQUEST`から抽出する
    let payload:usize = ((p[1] as usize) << 8) + p[2] as usize; 

    let mut buffer: Vec<u8> = Vec::with_capacity(1+2+payload+PADDING);
    buffer.push(TLS1_HB_RESPONSE);
    // `payload`-lengthをTLS1_HB_RESPONSEにシリアライズする
    buffer.extend(p[1..1+2].iter().cloned());                   
    // payload文字列をコピーする。境界チェックをしていないため、Heartbleed脆弱性を引き起こすはず!
    buffer.extend(p[3..3+payload].iter().cloned());             

    let mut rng = rand::thread_rng();                           
    buffer.extend( (0..PADDING).map(|_|rng.gen::<u8>())
            .collect::<Vec<u8>>() );

    if hbtype == TLS1_HB_REQUEST {
        let r = ssl3_write_bytes(s, TLS1_RT_HEARTBEAT, &*buffer);
        return r
    }
    Ok(())
}

上記のRust実装は、Heartbleedのexploitに対して、どのように動作するのでしょうか? 22バイトのpayload文字列を持つHeartbeatに対して、payload-lengthを257に設定してexploitを実行します。

  buffer.extend(p[3..3+payload].iter().cloned());

3+payloadは、スライスインデックスの上限です。payload文字列は22要素しか持っていないため、257は境界外です。 元々のCコードでは、これはBuffer over-readによりHeartbleedを引き起こします。

一方、Rustコードを実行したときの結果は、次の通りです。 プログラムは、panicメッセージとともにアボートします。 攻撃者は、依然としてDoS攻撃が可能ですが、データリークは発生しません。

thread '<main>' panicked at 'assertion failed: index.end <= self.len()',
Process didn't exit successfully: `target/release/heartbeat` (exit code: 101)

exploitに使ったコードは下記の通りです。

    let s: Ssl = Ssl {
        s3 : Rrec{
                rrec:  &[TLS1_HB_REQUEST, 1, 1, 14, 15, 16, 17,
                         18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
                         28, 29, 30, 31, 32]
        }
    };
    tls1_process_heartbeat(s).unwrap();
    }

Results

OpenSSLがRustで実装されていれば、Heartbleedバグは起こり得なかったでしょう。 Rustでは、データ構造を超えた読み込みができないため、悲惨なデータリークは発生しません。