HN 日本語サマリー

← 一覧へ戻る
プログラミング

V8 JavaScriptランタイムが私の定数時間JavaScriptライブラリを損なう

The V8 JavaScript Runtime Undermined My Constant-Time JavaScript Library (soatok.blog)

15 pointsby some_furry10 コメント

要約

筆者が6年前に公開した定数時間JavaScriptライブラリ「constant-time-js」に、V8 JavaScriptランタイムの最適化によってサイドチャネル脆弱性が生じることが判明しました。この脆弱性は、V8のToBooleanメカニズムや、-0と-1の異なる扱い、および真偽値オブジェクトへの直接アクセスに起因し、キャッシュ動作を通じて秘密情報が漏洩する可能性がありました。筆者は修正パッチをリリースし、CVEの申請を行いました。

全文翻訳

V8 JavaScriptランタイムが私の定時間JavaScriptライブラリを損なった

6年前に、私は「Soatok’s Guide to Side-Channel Attacks」というタイトルのブログ記事を書きました。この記事では、暗号アプリケーションにおけるサイドチャネルの一般的なトピックと、その回避方法について、PHPでのコード例を交えて議論しました。そこで指摘したように、この記事で議論されているアルゴリズムは、セキュリティ目標を損なうコンパイラやランタイムの最適化を排除することはできません。アルゴリズム定時間は達成できても、それ以上の保証は作業範囲外でした。

そのため、この記事ではアルゴリズム定時間が十分であると仮定します。

あなたの脅威モデルがこの仮定を受け入れられない場合は、ご自身で追加の努力を行い、結果を教えてください。結局のところ、空き時間に趣味でブログを書いているファーリーとして、私は正式検証における大規模な研究プロジェクトの予算を持っているわけではありません。

Soatok’s Guide to Side-Channel Attacks (2020年8月)

実演を容易にするために、デモ目的でconstant-time-jsという別のTypeScript / JavaScriptライブラリも作成しました。

長年にわたり、このデモコードは、NPMによると(少なくとも現時点では)依存パッケージはゼロでした。

[画像: Soatok Yay Sticker]

本番環境でこれに依存しないようにという私の警告は効果がありました!
アート: AJ_LovesDinos

ただし、これはオープンソースの依存関係のみを対象としています。一部のプロプライエタリソフトウェアが私の設計に基づいて構築したかどうかはわかりません。

先月、ファーリーコンベンションで、Yayu Wangという博士課程の学生から、constant-time-jsにおけるサイドチャネルを開示するメールを受け取りました。その後、ライブラリのバージョン0.5.0をリリースし、報告された問題を修正し、GitHubのセキュリティアドバイザリ機能を使用してCVEを申請しました。

完全なクレジットは以下の通りです。

脆弱性の発見と報告: Yayu Wang。研究: Yayu Wang、Kjell Dankert、Duy Kha Dinh、およびAastha Mehta、ブリティッシュコロンビア大学。

アドバイザリを投稿しCVEを申請した理由は単純です。もし誰かが実際にこのコードを使用している場合(例えば、私が可視性を持たないプロプライエタリソフトウェアシステムで)、npm auditまたはCVEの割り当てによって内部セキュリティメカニズムがトリガーされ、できるだけ早く最新バージョンにアップグレードするように促される可能性が高いからです。

緊急の事項(アドバイザリ、パッチなど)はすでに他で処理されたので、今回はこの発見のより興味深い部分とその修正について深く掘り下げるブログ記事を書こうと思いました。

なぜなら、私のブログを読む人は、あなたがオタクであるか、オタクのサブカルチャーの一員であるかのどちらかだから、少しオタクになってもいいのではないでしょうか?

目次

タイムライン

  • 2026-08-21: 16:51に開示メールを受信。16:54にYayuのみに「こんにちは、メールを受け取りました。現在旅行中で、火曜日までキーボードにアクセスできません。できるだけ早くフォローアップします。」と返信。
  • 2026-08-25: レポートを検証し、パッチを作成。
  • 2026-08-26: 返信全員宛てに、ローカルでテスト済みの提案パッチを添付して開示メールに返信。
  • 2026-09-09: Yayuから、パッチをテストし、リークが除去されたことを確認したとの返信。
  • 2026-09-10: 公開開示と新バージョンのタグ付け。
  • 2026-09-11: このブログ記事を作成。
    • 午前3時過ぎに投稿した。しまった。

レポートの検証

AI駆動の脆弱性ハンティングの時代において、深刻度のインフレと頻繁な幻覚に悩まされ、偽のレポートの割合が大幅に増加しています。バグが実際に存在し、研究者が主張するほど深刻であることを検証することは、メンテナーにとって常に重要でしたが、スラムに溺れているときは、より多くの強調を招きます。

最初のレポートメールは、修正のためのプルリクエストの説明で共有されており、全文を読むことができます。説明からの関連抜粋は、どこから探し始めるかを判断するのに十分な情報を提供しています。

constant-time-jsの条件選択関数は、ブランチレスJavaScript式を使用して選択マスクを構築します。select、select_alt、およびselect_intsは、V8で命令キャッシュとデータキャッシュの動作に秘密に依存する動作を生成することを発見しました。リークは、V8のToBooleanメカニズム、-0と-1の異なる処理、および異なるキャッシュライン上の生のtrueとfalse Oddballオブジェクトへの直接アクセスから生じます。結果として生じるキャッシュアクセスパターンは、選択条件の値を明らかにすることができます。

Yayuの開示メール

明らかに、最初にチェックすべきはV8のToBooleanメカニズムです。これはこのようになっています。

//ToString
//
//AccumulatorをStringに変換します。
IGNITION_HANDLER(ToBoolean, InterpreterAssembler) {
  TNode<Object> value = GetAccumulator();
  TVARIABLE(Boolean, result);
  Label if_true(this), if_false(this), end(this);
  BranchIfToBooleanIsTrue(value, &if_true, &if_false);
  BIND(&if_true);
  {
    result = TrueConstant();
    Goto(&end);
  }
  BIND(&if_false);
  {
    result = FalseConstant();
    Goto(&end);
  }
  BIND(&end);
  SetAccumulator(result.value());
  Dispatch();
}

おや、BranchIfToBooleanIsTrue()。その名前は、ブランチングサイドチャネルが公開されているように聞こえます。そして、確かに、それが観察されるものです。

次に、ブーリアンからの変換を見る必要があります。Yayuが観察したように:

selectの場合、!!returnLeft変換がV8のBuiltins_ToBooleanに到達することを確認しました。trueとfalseのケースでは、ビルトイン内の異なるアドレスで命令が実行されます。

別途、ハードウェアリードウォッチポイントを使用して、V8がブーリアン条件を変換している間にアクセスされたデータアドレスを測定しました。これにより、V8の読み取り専用ヒープ内の生のtrueおよびfalse Oddballオブジェクトからの直接読み取りが確認されました。

条件オブジェクトベースto_numberフィールド64バイトラインを含む
true0x...0c80x...0cc0x...0c0
false0x...0ac0x...0b00x...080

変換は、rdiが条件のOddballオブジェクトを指している状態で、次の形式のロードを実行します。

movl   rdx, [rdi - 0x1]  // Oddballのマップをロード
vmovsd xmm0, [rdi + 0x3] // Oddballのto_numberフィールドをロード

したがって、同じロード命令がtrueの場合は0x...0cc、falseの場合は0x...0b0にアクセスします。命令アドレスは同じでも、データオペランドは2つの異なるキャッシュラインを通じて条件を明らかにします。これは、実行された命令トレースだけでは操作を定時間と誤って分類できることを意味します。命令とデータのアドレスの両方を考慮する必要があります。

これはソースコードから観察可能であり、trueとfalseの値は静的に割り当てられています。

  static constexpr Tagged_t kUndefinedValue = 0x69;
  static constexpr Tagged_t kNullValue = 0x85;
  static constexpr Tagged_t kempty_string = 0xa1;
  static constexpr Tagged_t kFalseValue = 0xad;
  static constexpr Tagged_t kTrueValue = 0xc9;

これは報告された値に近く、0xc9はテーブルの0x...0c8から1つずれ、0xadも0x...0acから1つずれています。

[画像: Soatok thinking sticker]

不一致の正確な理由はわかりませんが、おそらく無害なもの(バイトアライメントやコンパイラ最適化など)でしょう。

アート: CMYKat

OddBallオブジェクトをさらに調べることができます…

@cppObjectLayoutDefinition
@apiExposedInstanceTypeValue(0x83)
@highestInstanceTypeWithinParentClassRange
extern class Oddball extends PrimitiveHeapObject {
  to_number_raw: float64;
  to_string: String;
  to_number: Number;
  type_of: String;
  kind: Smi;
}

…そしてTrue/Falseサブクラス定義です。

@cppObjectLayoutDefinition
@hasSameInstanceTypeAsParent
@doNotGenerateCast
extern class Boolean extends Oddball {}

@cppObjectLayoutDefinition
@hasSameInstanceTypeAsParent
@doNotGenerateCast
extern class True extends Boolean {}

@cppObjectLayoutDefinition
@hasSameInstanceTypeAsParent
@doNotGenerateCast
extern class False extends Boolean {}

これにより、内部的にはtrueとfalseの両方がOddBallから継承されているため、OddBallのみをトリガーするブランチはすべて関連があることがわかります。これは現在関連していませんが、後続のコードスニペットで覚えておく価値があります。

全体として、これはレポートのブーリアン型側面を確認します。

観察: ブーリアンを避けることが安全を確保する唯一の方法です。

次に、二重否定(!!)の動作と、V8が符号付きゼロをどのように処理するかを調べる必要があります。IEEE 754では-0は特別な値であり、V8のようなJavaScriptランタイムはこれをサポートしていますが、そのサポート方法がタイミングリークにつながる可能性があります(Yayuによって報告されたように)。

AccessBuilderクラスから、HeapNumber::value_とOddBall::to_number_raw_が同等であることがわかります。

// static
FieldAccess AccessBuilder::ForHeapNumberOrOddballOrHoleValue() {
  STATIC_ASSERT_FIELD_OFFSETS_EQUAL(offsetof(HeapNumber, value_), offsetof(Oddball, to_number_raw_));
  STATIC_ASSERT_FIELD_OFFSETS_EQUAL(offsetof(HeapNumber, value_), Hole::kRawNumericValueOffset);
  return ForHeapNumberValue();
}

また、単項演算子を見て、OddBallsに焦点を当てて、以前の分析と結びつけましょう。

  TNode<Object> UnaryOpWithFeedback(TNode<Context> context, TNode<Object> value,
                                    TNode<UintPtrT> slot,
                                    TNode<HeapObject> maybe_feedback_vector,
                                    const SmiOperation& smi_op,
                                    const FloatOperation& float_op,
                                    const BigIntOperation& bigint_op,
                                    UpdateFeedbackMode update_feedback_mode) {
    TVARIABLE(Object, var_value, value);
    TVARIABLE(Object, var_result);
    TVARIABLE(Float64T, var_float_value);
    TVARIABLE(Smi, var_feedback, SmiConstant(BinaryOperationFeedback::kNone));
    TVARIABLE(Object, var_exception);
    Label start(this, {&var_value, &var_feedback}), end(this);
    Label do_float_op(this, &var_float_value);
    Label if_exception(this, Label::kDeferred);
    Goto(&start);
    // ToNumeric変換後に再試行する必要がある場合があります。
    BIND(&start);
    {
      Label if_smi(this), if_heapnumber(this), if_oddball(this);
      Label if_bigint(this, Label::kDeferred);
      Label if_other(this, Label::kDeferred);
      value = var_value.value();
      GotoIf(TaggedIsSmi(value), &if_smi);

      TNode<HeapObject> value_heap_object = CAST(value);
      TNode<Map> map = LoadMap(value_heap_object);
      GotoIf(IsHeapNumberMap(map), &if_heapnumber);
      TNode<Uint16T> instance_type = LoadMapInstanceType(map);
      GotoIf(IsBigIntInstanceType(instance_type), &if_bigint);
      Branch(InstanceTypeEqual(instance_type, ODDBALL_TYPE), &if_oddball,
             &if_other);

      BIND(&if_smi);
      {
        var_result = smi_op(CAST(value), &var_feedback, &do_float_op, &var_float_value);
        Goto(&end);
      }

      BIND(&if_heapnumber);
      {
        var_float_value = LoadHeapNumberValue(value_heap_object);
        Goto(&do_float_op);
      }

      BIND(&if_bigint);
      {
        var_result = bigint_op(context, value_heap_object);
        CombineFeedback(&var_feedback, BinaryOperationFeedback::kBigInt);
        Goto(&end);
      }

      BIND(&if_oddball);
      {
        // ここで以前のフィードバックとのOrを必要としません。なぜなら、値を数値に変換すると、このパスに到達できなくなるからです。最初のパスでフィードバックがkNoneの場合にのみ、このパスに到達できます。
        CSA_DCHECK(this, SmiEqual(var_feedback.value(),
                                  SmiConstant(BinaryOperationFeedback::kNone)));
        OverwriteFeedback(&var_feedback,
                          BinaryOperationFeedback::kNumberOrOddball);
        var_value = LoadOddballToNumber(CAST(value_heap_object));
        Goto(&start);
      }

      BIND(&if_other);
      {
        // ここで以前のフィードバックとのOrを必要としません。なぜなら、値を数値に変換すると、このパスに到達できなくなるからです。最初のパスでフィードバックがkNoneの場合にのみ、このパスに到達できます。
        CSA_DCHECK(this, SmiEqual(var_feedback.value(),
                                  SmiConstant(BinaryOperationFeedback::kNone)));
        OverwriteFeedback(&var_feedback, BinaryOperationFeedback::kAny);
        {
          ScopedExceptionHandler handler(this, &if_exception, &var_exception);
          var_value = CallBuiltin(Builtin::kNonNumberToNumeric, context,
                                  value_heap_object);
        }
        Goto(&start);
      }
    }

    BIND(&if_exception);
    {
      UpdateFeedback(var_feedback.value(), maybe_feedback_vector, slot,
                     update_feedback_mode);
      CallRuntime(Runtime::kReThrow, context, var_exception.value());
      Unreachable();
    }

    BIND(&do_float_op);
    {
      CombineFeedback(&var_feedback, BinaryOperationFeedback::kNumber);
      var_result = AllocateHeapNumberWithValue(
          float_op(var_float_value.value()));
      Goto(&end);
    }

    BIND(&end);
    UpdateFeedback(var_feedback.value(), maybe_feedback_vector, slot,
                   update_feedback_mode);
    return var_result.value();
  }

最初のハイライトは、フィードバックが未定義でない場合に実行されるブランチショートサーキットです。2番目のハイライトは、このフィードバックメカニズムは最初のパスでのみ未定義になり得ることを説明しています。これは、サイドチャネル耐性のあるコードを困難にするエンジン最適化の一種です。

最後に、単項否定(定時間プログラミングで使用される(-var) & 0xffパターンに影響します)を見る必要があります。

  TNode<Object> Negate(TNode<Context> context, TNode<Object> value,
                       TNode<UintPtrT> slot,
                       TNode<HeapObject> maybe_feedback_vector,
                       UpdateFeedbackMode update_feedback_mode) {
    SmiOperation smi_op = [=, this](TNode<Smi> smi_value, TVariable<Smi>* var_feedback,
                                  Label* do_float_op, TVariable<Float64T>* var_float) {
      TVARIABLE(Number, var_result);
      Label if_zero(this), if_min_smi(this), end(this);
      // オペランドが0の場合は-0を返します。
      GotoIf(SmiEqual(smi_value, SmiConstant(0)), &if_zero);

      // オーバーフローを避けるために最小Smiを特別処理します。
      GotoIf(SmiEqual(smi_value, SmiConstant(Smi::kMinValue)), &if_min_smi);

      // それ以外の場合は、オペランドを0から単純に減算します。
      CombineFeedback(var_feedback, BinaryOperationFeedback::kSignedSmall);
      var_result = SmiSub(SmiConstant(0), smi_value);
      Goto(&end);

      BIND(&if_zero);
      CombineFeedback(var_feedback, BinaryOperationFeedback::kNumber);
      var_result = MinusZeroConstant();
      Goto(&end);

      BIND(&if_min_smi);
      *var_float = SmiToFloat64(smi_value);
      Goto(do_float_op);

      BIND(&end);
      return var_result.value();
    };
    FloatOperation float_op = [=, this](TNode<Float64T> float_value) {
      return Float64Neg(float_value);
    };
    BigIntOperation bigint_op = [=, this](TNode<Context> context,
                                          TNode<HeapObject> bigint_value) {
      return CAST(CallRuntime(Runtime::kBigIntUnaryOp, context, bigint_value,
                              SmiConstant(Operation::kNegate)));
    };
    return UnaryOpWithFeedback(context, value, slot, maybe_feedback_vector,
                               smi_op, float_op, bigint_op,
                               update_feedback_mode);
  }

BIND(&if_zero)は定数を返します。

BIND(&if_min_smi)はSmiToFloat64を呼び出し、do_float_opにジャンプします。これは、全体として、定数を返すのと同じ時間で実行されることは保証されていません。

コードをコンパイルしたり、JIT動作をインストルメントする方法を理解したりすることなく、このコードがサイドチャネルを持つように設計されていることを確信できます。

これにより、Yayuのレポート全体が確認されました。問題の所在がわかったので、修正方法を考えましょう。

パッチの作成

報告された問題に対するパッチを作成するにあたり、以下の目標を設定しました。

  1. ブーリアン型を一切使用しない。二重否定(!!var)や等価演算子(===、!==)も含まれます。
  2. -0(符号付きゼロ)のV8での処理方法のため、否定とマスクを避ける。

例えば、is_nonzero関数の最終ステップはこのようになりました。

/**
 * この数値はゼロ以外ですか?
 *
 * @param {Uint8Array} num
 * @returns {boolean}
 */
export function is_nonzero(num: Uint8Array): boolean {
    let d: number = 0;
    for (let i: number = num.length - 1; i >= 0; i--) {
        d |= num[i];
    }
    return d !== 0;
}

ブーリアン型を避けるために、加算とビットシフトを使用できます。しかし、これはis_nonzero()の契約を変更します。なぜなら、それはnumberを返すようになるからです。互換性の問題を最小限に抑えるために、内部関数(ブーリアン値ではなく数値ビットフラグを返すためis_nonzero_flag()と名付けました)を書き、既存のブーリアンis_nonzero()を維持します。これは新しい関数を呼び出し、その値を1と比較するだけです。

@@ -95,11 +95,25 @@ export function gcd(a: Uint8Array, b: Uint8Array): Uint8Array { export function is_nonzero(num: Uint8Array): boolean {
+    return is_nonzero_flag(num) === 1;
+}
+
+/**
+ * この数値がゼロでない場合は1を、そうでない場合は0を返します。
+ *
+ * この数値形式は、秘密から導出されたブーリアンがV8のToBooleanメカニズムを通過しないように、選択関数への入力として使用されます。
+ *
+ * @param {Uint8Array} num
+ * @returns {number}
+ */
+function is_nonzero_flag(num: Uint8Array): number {
     let d: number = 0;
     for (let i: number = num.length - 1; i >= 0; i--) {
         d |= num[i];
     }
-    return d !== 0;
+    // dは[0, 255]の範囲にあるため、これはゼロを0に、それ以外のすべての値を1にマッピングします。
+    return (d + 0xff) >>> 8;
 }

既存の外部APIにはまだブーリアン型の問題がありますが、is_nonzero()がライブラリ内で使用されていた場所は、秘密情報に対してループで呼び出されていたため、より問題がありました。以前のセクションのpow()の実装も参照してください。そのパッチは次のようになります。

@@ -315,7 +329,7 @@ export function pow(a: Uint8Array, n: Uint8Array): Uint8Array { rshift1(e, true);
         Sprime = multiply(S, S);
         S = select(
-            is_nonzero(e),
+            is_nonzero_flag(e),
             Sprime,
             normalize(S, Sprime.length)
         );

select()を定時間にするために、同じアプローチが使用されます。

  • ブーリアンを数値に置き換える(ブーリアン特有の問題を回避するため)
  • マスク生成戦略を!!を使用しないように変更する
    • ブーリアンを扱わなくなったため、!!xは(x & 1)にできます。
  • 中間計算で符号付きゼロを避ける
    • -xは0 - (x & 1)に置き換えることができます。ゼロからの減算は、否定とは異なる操作です。

select.tsへの変更は非常に短いです。

diff --git a/lib/select.ts b/lib/select.ts
index df6966d..7a8d4c3 100644
--- a/lib/select.ts
+++ b/lib/select.ts
@@ -1,21 +1,24 @@
 import { int32 } from './int32';
 
 /**
- * If TRUE, return left; else, return right.
+ * choiceが1の場合はleftを、0の場合はrightを返します。
  *
- * @param {boolean} returnLeft
+ * @param {number} choice (0 or 1)
  * @param {Uint8Array} left
  * @param {Uint8Array} right
  * @returns {Uint8Array}
  */
-export function select(returnLeft: boolean, left: Uint8Array, right: Uint8Array): Uint8Array {
+export function select(choice: number, left: Uint8Array, right: Uint8Array): Uint8Array {
     if (left.length !== right.length) {
         throw new Error('select() expects two Uint8Array objects of equal length');
     }
     /*
-     If returnLeft, mask = 0xFF; else, mask = 0x00;
+     choiceが1の場合はmask = 0xFF、それ以外の場合はmask = 0x00。
+
+     正のゼロからの減算として維持します。単項否定はゼロの選択に対して-0を生成しますが、これはV8ではSmi値-1とは異なる方法で表現されます。
      */
-    const mask: number = (-!!returnLeft) & 0xff;
+    const mask: number = (0 - (choice & 1)) & 0xff;
     const out: Uint8Array = new Uint8Array(left.length);
     for (let i: number = 0; i < left.length; i++) {
         out[i] = right[i] ^ ((left[i] ^ right[i]) & mask);
@@ -35,7 +38,8 @@ export function select_alt(choice: number, m: Uint8Array, n: Uint8Array): Uint8A
     if (m.length !== n.length) {
         throw new Error('Both Uint8Arrays must be the same length');
     }
-    const mask = (-choice) & 0xff;
+    // select()を参照: 正のゼロからの減算は-0の具現化を回避します。
+    const mask = (0 - (choice & 1)) & 0xff;
     const out = new Uint8Array(m.length);
     for (let i: number = 0; i < out.length; i++) {
         out[i] = n[i] ^ ((m[i] ^ n[i]) & mask);
@@ -55,7 +59,8 @@ export function select_ints(returnLeft: number, left: number, right: number): nu
     /*
      If returnLeft, mask = 0xFFFFFFFF; else, mask = 0x00000000;
      */
-    const mask: number = (-(returnLeft & 1)) & 0xfffffffff;
+    // select()を参照: 正のゼロからの減算は-0の具現化を回避します。
+    const mask: number = 0 - (returnLeft & 1);
     return right ^ ((left ^ right) & mask);
 }

この時点でもまだ終わっていませんでしたが、テスト、READMEの更新など、いくつかの管理上の作業に対処する必要がありました。また、CI設定を修正する必要もありました。しかし、それらは興味深いものではありません。問題を片付けられるという手品のような力をもって、先に進みましょう。

修正の検証

パッチが最初に報告された特定パターンを修正したからといって、他のリークが存在しない、あるいは私の修正が完全であるという意味ではありません。

実際、前のセクションで最初の例として使用したis_nonzero()とpow()のリークは、select()を修正し、他の型システムへの違反を探した後に特定されました。もし報告された問題の範囲だけを見ていたら、そのリークを見逃していたかもしれません。

しかし、まあ。ブーリアンは問題の大部分を占めるので、ブーリアン変数をまったく使用しないようにすることは検証が簡単です。このコードは、以前ブーリアンを使用していた場所で数値と単項演算子のみを使用していますか?はい。

対処すべきもう一つの興味深い質問は、「-0をうまく回避できたか?」です。

上記を思い出していただければ、差分にはすべてこのパターンがありました。

-    const mask: number = (-!!returnLeft) & 0xff;
+    const mask: number = (0 - (choice & 1)) & 0xff;

修正されたコードは、ビットごとのAND操作(これらが定時間でない場合、この努力全体は無駄になります)と、ゼロからの単一の減算として解釈されます。

したがって、減算がV8によってどのように処理されるかを見るだけで済みます(これはV8のソースコードへのリンクです)。

TNode<Object> BinaryOpAssembler::Generate_SubtractWithFeedback(
    const LazyNode<Context>& context, TNode<Object> lhs, TNode<Object> rhs,
    TNode<UintPtrT> slot_id, const LazyNode<HeapObject>& maybe_feedback_vector,
    UpdateFeedbackMode update_feedback_mode, bool rhs_known_smi) {
  auto smiFunction = [=, this](TNode<Smi> lhs, TNode<Smi> rhs,
                               TVariable<Smi>* var_type_feedback) {
    Label end(this);
    TVARIABLE(Number, var_result);
    // rhsがSubSmiの場合はSmi操作を高速パスしたいので、Smiであることがわかっている場合(SubSmiの場合)は、Smi操作を高速パスしたいです。通常のSub操作の場合は、SmiとNumberの両方の操作を高速パスしたいので、このパスはDeferredとしてマークされるべきではありません。
    Label if_overflow(this,
                      rhs_known_smi ? Label::kDeferred : Label::kNonDeferred);
    var_result = TrySmiSub(lhs, rhs, &if_overflow);
    *var_type_feedback = SmiConstant(BinaryOperationFeedback::kSignedSmall);
    Goto(&end);

    BIND(&if_overflow);
    {
      *var_type_feedback = SmiConstant(BinaryOperationFeedback::kNumber);
      TNode<Float64T> value = Float64Sub(SmiToFloat64(lhs), SmiToFloat64(rhs));
      var_result = AllocateHeapNumberWithValue(value);
      Goto(&end);
    }

    BIND(&end);
    return var_result.value();
  };
  auto floatFunction = [=, this](TNode<Float64T> lhs, TNode<Float64T> rhs) {
    return Float64Sub(lhs, rhs);
  };
  return Generate_BinaryOperationWithFeedback(
      context, lhs, rhs, slot_id, maybe_feedback_vector, smiFunction,
      floatFunction, Operation::kSubtract, update_feedback_mode, rhs_known_smi);
}

ハイライトされたセクションでは、rhs_known_smiは通常falseです(これは計算された値であり、バイトコード即値ではないため)。これは、決定がTrySmiSubによって行われることを意味します。そこでは次のようになります。

  • lhsは0になります。
  • rhsは(choice & 1)の結果になります。

これはオーバーフローしないため(INT32_MINからINT32_MAXまでの任意の数値はオーバーフローしたとは見なされません。Smiタグ付けはここに実装されています)、if_overflowラベルは決してジャンプされません。

現在、簡単に間違える可能性のあることは次のとおりです。

  • コンパイラが悪意を持っていて、私が予期しない「最適化」を導入する。
  • V8開発者が悪意を持っており、将来のリリースでこのロジックの動作を変更して私の仮定を壊す。
  • パッケージ管理によるその他のサプライチェーン的な悪ふざけ。

そして、まあ、それは脅威モデルの一部ではありませんよね?

上記でコンパイラやV8開発者が「悪意がある」と述べたとき、私は完全に冗談めかして言いました。どちらの当事者も文字通り悪意があるとは思いません。

これは明白だと思いましたが、Hacker Newsのコメンターがそれに気分を害したので、これが私の明確化であると考えてください。

V8開発チームも、さまざまなC/C++コンパイラプロジェクトも、私の小さなデモコードには全く関心がありません。彼らもそうすべきではありません。

「存在する必要はないと思った明確化」を300ドルでお願いします、アレックス。

修正パッチを作成した後、差分を生成し、Yayuにメールで送りました。Yayuは、パッチが適用されたコードに自分の方法論を適用した際に、同じリークを観察しませんでした。

より高い保証はどのようなものか

前のセクションでは、サイドチャネル脆弱性が現実であることを検証するために行った努力、パッチの作成方法、そしてパッチが実際に問題を解決したことを確認した方法について説明しました。

これを読んで、「なぜJavaScriptでこれをやろうとしているのか?」と疑問に思う人もいるでしょう。

「自分で暗号を実装しない」という問題を解決する唯一の方法は、開発者と協力することです。彼らに、専門家の精査を受けた高品質なツールを提供すれば、自分で暗号を実装する理由がなくなるでしょう。

暗号ツールを必要とする多くの開発者は、セキュリティオタクや暗号学者が自発的にも容易にも使用しない言語でソフトウェアを書いています。

そのため、私は少なくとも彼らの満たされていないニーズを満たす努力をすることにしました。

[画像: 「試み」という言葉が書かれた、下手な星の絵]

それでも何かはあります!

しかし、より興味深い質問は、「さらに良くできるか?」ということです。

一つの魅力的な解決策は、すべての暗号をRust(または他のセキュリティオタクが承認した言語)で書き、アルゴリズムをWebAssemblyにコンパイルし(または、サーバーサイドのみの場合はFFIを使用する)、解釈されたコードを等時性にする試みをしないことです。

残念ながら、WebAssemblyには実際の定時間保証がありません。現時点では、提案が存在しますが、4年間更新されていません。

話はそれだけですが、一般的に言って、システムプログラミング言語(C、Rustなど)にも保証はありません。

しかし、それを行うための進行中の作業はあります。

結びの言葉

このデモコードに私が誤って導入してしまったサイドチャネル脆弱性についての興味深い掘り下げになったことを願っています。

ヘッダーアートはAJ_LovesDinosが作成しました。