Skip to main content

JavaScriptの型混同:入力検証の回避とその修正方法

著者
Headshot of Alessio Della Libera

Alessio Della Libera

blog feature snyk open source blue

2021年11月3日

0 分で読めます

以前のブログ記事では、型の操作(型混同)によってテンプレートサンドボックスを脱出し、クロスサイトスクリプティング(XSS)やコードインジェクションの脆弱性につながる可能性があることを紹介しました。

今回の調査の主な目的の1つは、JavaScriptエコシステムにおいて、型混同攻撃(予期しない型の入力を与える攻撃)でセキュリティ修正や入力検証を回避できるかどうか、またその方法を探ることでした。

まず、プロトタイプ汚染やXSSなどの一般的なセキュリティ脆弱性がどのように修正されているかを調査しました。この調査には、業界最大のオープンソース脆弱性データベースであるSnyk Intel Vulnerability Databaseのデータを活用しました。この情報から、さまざまなメンテナーが特定の種類の脆弱性を防ぐために用いるパターンをいくつか特定できました。

次に、型混同の脆弱性を見ていきましょう。この記事では、予期しない型の入力を与えることで、入力のサニタイズや検証を回避できる一般的なシナリオを紹介します。また、この種の入力検証の回避を修正したいオープンソースメンテナー向けに、修正例や提案も紹介します。

JavaScriptの基礎

配列値を使って入力検証を回避し、潜在的なセキュリティ脆弱性につなげる方法を説明する前に、まずJavaScriptの基本的な仕組みを確認しましょう。特に、後ほどプロトタイプ汚染の事例を取り上げる際に役立ちます。

値の比較:===と==

JavaScriptでは、値の比較に使える演算子が2つあります。

  • 厳密等価演算子===

  • 等価演算子==

主な違いの1つは、===演算子は両方のオペランドの型が異なる場合、常にfalseを返すことです(型変換は行いません)。一方、==演算子では、オペランドの型が異なる場合、まず共通の型に変換してから比較します。これらの演算子の詳しい動作については、公式ドキュメントの厳密等価演算子と等価演算子をご覧ください。

次の例で、この動作を確認できます。

let a = "test"
console.log(a == "test") // true
console.log(a === "test") // true

let b = ["test"]
console.log(b == "test") // true: ["test"] is first converted to "test" and then compared
console.log(b === "test") // false!

同じ値と型を持つオペランドを比較する場合、==演算子と===演算子はどちらもtrueを返します(2行目と3行目)。一方、もう一方のオペランドと同じ文字列表現を持つ配列を比較する場合、==演算子を使ったときだけtrueを返します。6行目では、値["test"]がまず文字列表現に変換され、もう一方のオペランド"test"と比較されます。オペランドの型が異なる場合、===演算子は常にfalseを返します(7行目)。

注:上記の考え方は、不等価演算子!==と!=にも当てはまります。

値の文字列表現を取得する方法には、次のようなものがあります。

  • toString()メソッドを呼び出す(2行目)

  • String関数を使って文字列に変換する(2行目)

  • +演算子を使って値と空文字列を連結する(3行目)

let arr = ["test"]
console.log(arr.toString() === "test") // true
console.log(String(arr) === "test") // true
console.log(('' + arr) === "test") // true

配列値の文字列表現は、各要素をカンマで連結したものです。

プロパティアクセサー

JavaScriptのもう1つの重要な機能として、ブラケット記法でオブジェクトのプロパティにアクセスする際、文字列以外の型の値も指定できます。値が文字列(またはSymbol)でない場合は、まず文字列に変換してからオブジェクトのプロパティへのアクセスに使用されます。

次の例を見てみましょう。

let obj = {}

let prop1 = ["test"]
obj[prop1] = 1
console.log(prop1.toString()) // 'test'
console.log(obj["test"]) // 1

let prop2 = {} // empty object
obj[prop2] = 2
console.log(prop2.toString()) // [object Object]
console.log(obj['[object Object]']) // 2

let prop3 = [] // empty array
obj[prop3] = 3
console.log(prop3.toString()) // ''
console.log(obj['']) // 3

このように、プロパティへのアクセスに使うキーは、さまざまな型にできます。4行目では、プロパティ["test"]がまず文字列(つまり"test"という値)に変換され、その結果がオブジェクトのプロパティにアクセスするキーとして使われます。実際、6行目で文字列"test"を使うと、4行目で設定した値を取得できます。

同様に、文字列表現が[object Object]となる空のオブジェクト{}(9行目)を使ってオブジェクトの値を書き込んだ場合、文字列[object Object]を直接指定することで同じ値にアクセスできます(11行目)。

StringとArrayのメソッド

StringとArrayの両方に定義され、同じ名前を持つ組み込みメソッドがあります。たとえばincludes、indexOf、lastIndexOfなどです。

この記事でこれが重要なのはなぜでしょうか。これらのメソッドは、呼び出し対象の入力型によって動作が異なります。いずれかのメソッドをユーザー入力の検証に使っている場合、型の異なる入力(たとえば配列)が与えられ、型のチェックが行われなければ、検証を回避されるおそれがあります。

次の例では、StringとArrayの両方に定義された組み込みメソッドの動作の違いを示します。

let a = "<script>"
console.log(a.includes("<script>")) // true
console.log(a.indexOf("<")) // 0

let b = ["<script>"]
console.log(b.includes("<script>")) // true
console.log(b.indexOf("<")) // -1

let c = [["<script>"]]
console.log(c.includes("<script>")) // false
console.log(c.indexOf("<")) // -1

console.log(a.toString() === b.toString()) // true
console.log(b.toString() === c.toString()) // true

注:この例の値("<script>"、["<script>"]、[["<script>"]])はすべて同じ文字列表現になります。

たとえば3行目では、String.prototype.indexOf()メソッドが呼び出されます。このメソッドは文字列に文字<が含まれているか確認し、最初に見つかった位置を返します。含まれていない場合は-1を返します。

しかし、入力が配列の場合はArray.prototype.indexOf()メソッドが呼び出され、配列に要素<が含まれているか確認します。配列には文字列"<script>"という要素が1つしかないため、この関数は-1を返し、その文字に対するサニタイズが回避される可能性があります。

JavaScriptの基本概念のまとめ

ここまでの内容をまとめると、次のとおりです。

  • オペランドの型が異なる場合、===演算子は常にfalseを返す

  • ブラケット記法を使うと、異なる型のキーでオブジェクトのプロパティにアクセスできる

  • StringとArrayの両方に定義された一部の組み込みメソッド(indexOfやincludesなど)は、入力の型によって動作が異なる

次に進む前に、ここまでに取り上げたメソッドを使ってユーザー入力を検証する、次のシナリオを見てみましょう。

次のチェックだけでは、オブジェクトobjにキーisAdminが設定されるのを防げない可能性があります(変数propはユーザーが制御できるものとします)。

let prop = ["isAdmin"] // user controlled

let obj = {}

if(prop === "isAdmin") { // ["isAdmin"] === "isAdmin" -> false
    throw new Error("Ops!");
} else {
    obj[prop] = true; // obj[["isAdmin"]] is equivalent to obj["isAdmin"]
}

console.log(obj["isAdmin"]) // true

propの値を"isAdmin"に変更すると、上記の例では例外が発生します。

次は、入力に危険な文字が含まれていないかを検出し、潜在的なXSSを防ぐために使われる、もう1つの「弱い」チェックの例です。

let user = ["<img src=x onerror='alert(1)'/>"] // user controlled

if (user.indexOf("<") || user.indexOf(">")){
    throw new Error("Characters not allowed!");
}

message = "Hello : " + user
console.log(message) // Hello : <img src=x onerror='alert(1)'/>

このような回避が可能なのは、特定の条件を満たす場合に限られる点が重要です。たとえば、入力が特定のソースから来ており(次のセクションを参照)、入力に対してStringにのみ定義された組み込みメソッドが呼び出されないことが条件です。そうでなければ、他の型では定義されていないため、アプリケーションはエラーや例外を返します。

次の例では、入力に対してtoLowerCase()メソッドを呼び出しています。配列値によって===の比較を回避できることはすでに見ましたが、配列が渡された場合、toLowerCase()は文字列にしか定義されていないため、アプリケーションは例外をスローします。

let prop = ["isAdmin"] // user controlled

let obj = {}

prop = prop.toLowerCase() // Uncaught TypeError: toLowerCase is not a function (it's only defined on String)

if(prop === "isAdmin") {
   throw new Error("Ops!");
} else {
   obj[prop] = true;
 }

console.log(obj["isAdmin"])

配列値を取得する方法

ここで、リモートデータから配列値を取得するにはどうすればよいのか、疑問に思うかもしれません。

配列値を取得する方法はいくつかあります。

  • サーバーサイド:一般的なExpressフレームワークを使っている場合、req.queryやreq.body(express.json()ミドルウェアを使用している場合)の値は、文字列だけでなく配列やオブジェクトとしても解析されることがあります。

  • クライアントサイド:postMessage APIから渡されるデータ。

サーバーサイド

GETリクエストの場合、一般的なExpressフレームワークを使い、値がreq.queryから渡されるなら、parameter[]=valueという表記で配列値を取得できます。一般に、これは一般的なquerystring解析ライブラリであるqsを使用するあらゆるアプリケーションに当てはまります。

POSTリクエストの場合、express.json()ミドルウェアを使用すると、リクエスト本文はJSONオブジェクトとして解析されます。

次のExpressアプリケーションでは、配列値を取得する方法を示します。

const express = require('express')
const app = express()
const port = 3000

// curl -i -X GET "https://localhost:3000/test1?path[0][]=foo&path[]=bar&val=test"
app.get('/test1', (req, res) => {
    console.log(typeof req.query.path, req.query.path); // object [ [ 'foo' ], 'bar' ]   
    res.send('Hello World!')
})

app.use(express.json());

// curl -i -H "Content-Type: application/json" -X POST --data '{"path":[["foo"], "bar"], "val":"test"}' "https://localhost:3000/test2"
app.post('/test2', (req, res) => {
    console.log(typeof req.body.path, req.body.path); // object [ [ 'foo' ], 'bar' ]   
    res.send('Hello World!')
})

app.listen(port, () => {
    console.log(`App listening at https://localhost:${port}`)
})

クライアントサイド

postMessage APIから渡されるデータも、文字列以外の型にできます。

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
  postMessage example
  <script>
      window.addEventListener('message', function(event) {
          let name = event.data.name
          console.log(typeof name, name)
      });
  </script>
</body>
</html>

上記のコードをテストするには、ブラウザーの開発者コンソールを開き、次を実行します:window.postMessage({name: ["array"]}, "*")。次の出力が表示されます。

配列値を含むオブジェクトを使ったpostMessageの例を表示する、ブラウザーの開発者コンソール。

調査結果

今回の調査で発見し、報告した問題の一部を以下に示します。

モジュール

脆弱性

Snykアドバイザリ

CVE

object-path

プロトタイプ汚染

CVE-2021-23434

immer

プロトタイプ汚染

CVE-2021-23436

mpath

プロトタイプ汚染

CVE-2021-23438

set-value

プロトタイプ汚染

CVE-2021-23440

edge.js

クロスサイトスクリプティング(XSS)

CVE-2021-23443

jointjs

プロトタイプ汚染

CVE-2021-23444

datatables.net

クロスサイトスクリプティング(XSS)

CVE-2021-23445

teddy

クロスサイトスクリプティング(XSS)

CVE-2021-23447

脆弱性の開示に従い、複数のメンテナーに非公開で責任ある形で連絡しました(現在も開示プロセス中の調査結果があります)。

何よりも、連絡を差し上げたすべてのメンテナーの皆さまに、ご対応いただいたことへの感謝を申し上げます。

次の事例では、配列値を渡すことで、プロトタイプ汚染の修正とXSSの入力検証をどのように回避できたのかを説明します。

事例:object-pathのプロトタイプ汚染

object-pathは、パスを使って深い階層にあるプロパティにアクセスするためのライブラリです。このライブラリは配列形式のパスも受け付けるため、['part1', 'part2', etc.]の形式でパスを指定できます。このライブラリにはすでにプロトタイプ汚染の脆弱性(CVE-2020-15256)がありました。導入された修正は次のとおりです。

if (options.includeInheritedProps && (currentPath === '__proto__' ||
  (currentPath === 'constructor' && typeof currentValue === 'function'))) {
  throw new Error('For security reasons, object\'s magic properties cannot be set')
}

この修正では、パスに危険な要素が含まれている場合(それらが文字列であれば)、正しく例外をスローします。実際、次のペイロードを使うと、関数はエラーをスローします。

const objectPath = require('object-path');

objectPath.withInheritedProps.set({}, ['__proto__', 'polluted'], 'yes');
console.log(polluted); // Error: For security reasons, object's magic properties cannot be set

しかし、先ほど見たように、次のことができます。

  • ブラケット記法でオブジェクトのプロパティにアクセスする場合、任意の型のキーを使用できる

  • オペランドの型が異なる場合、===演算子はfalseを返します。条件currentPath === '__proto__'は、currentPathが['__proto__']の場合falseを返します(constructorの場合も同様です)。

つまり、危険なキーをそのキー自身だけを含む配列でラップすれば修正を回避でき、プロトタイプ汚染を引き起こすことができます。

const objectPath = require('object-path');

objectPath.withInheritedProps.set({}, [['__proto__'], 'polluted'], 'yes');
console.log(polluted); // yes

修正方法

Snykは2021年8月25日にメンテナーへ非公開で連絡し、この問題は2021年8月27日にv0.11.6で速やかに修正されました。導入された修正では、チェックの前にパスの各要素を文字列に変換することで、このシナリオを防ぎます(数値型または文字列型でない場合)。

var currentPath = path[0];
if (typeof currentPath !== 'string' && typeof currentPath !== 'number') {
  currentPath = String(currentPath)
}
var currentValue = getShallowProperty(obj, currentPath);
if (options.includeInheritedProps && (currentPath === '__proto__' ||
  (currentPath === 'constructor' && typeof currentValue === 'function'))) {
  throw new Error('For security reasons, object\'s magic properties cannot be set')
}

前述のペイロードは、もう機能しません。

const objectPath = require('object-path');

objectPath.withInheritedProps.set({}, [['__proto__'], 'polluted'], 'yes');
console.log(polluted); // Error: For security reasons, object's magic properties cannot be set

ケーススタディ:edge.jsにおけるクロスサイトスクリプティング(XSS)

edge.jsはNode.jsのテンプレートエンジンです。このライブラリには、XSSなどのセキュリティ脆弱性を防ぐために有効化できる機能が組み込まれています。特に、ドキュメントによると、「補間(波括弧内のコード)の出力は、XSS攻撃を防ぐためにHTMLエスケープされます」。XSSを防ぐためにHTMLの危険な文字をエスケープする元の関数は、次のとおりです。

// https://github.com/edge-js/edge/blob/1ade7fbb81fbc1b52757650214d6baca140d3eb0/src/Template/index.ts#L183
public escape<T>(input: T): T extends SafeValue ? T['value'] : T {
  return typeof input === 'string'
    ? string.escapeHTML(input)
    : input instanceof SafeValue
    ? input.value
    : input
}

ご覧のとおり、inputがstring型の場合にのみHTMLエスケープされます。つまり、ユーザーが制御できる入力がオブジェクト型(配列など)で、SafeValueではない場合、テンプレートで{{ }}を使用しても、エスケープされずに返されます(エラーや例外も発生しません)。その結果、XSSの脆弱性につながる可能性があります。

これがどのように悪用されるかを示すために、このライブラリをテンプレートエンジンとして使用し、ユーザーが制御する値をテンプレートに描画する次のWebアプリケーションを見てみましょう。

const express = require('express')
const app = express()
const port = 3000
const { join } = require('path')
const edge = require('edge.js').default

edge.mount(join(__dirname, 'views'))

// curl -i -X GET "https://localhost:3000/test?name[]=%3Cimg%20src=x%20onerror=%27alert(1)%27%20/%3E"
app.get('/test', (req, res) => {
    let n = req.query.name

    console.log(typeof n, n);

    edge.render('welcome', {
      greeting: n
    }).then(html => res.send(html))
})

app.listen(port, () => {
    console.log(`App listening at https://localhost:${port}`)
})

ファイルviews/welcome.edgeの内容は次のとおりです。

<p> {{ greeting }} </p>

前述のとおり、特定のソースから取得したデータは、異なる型になる場合もあります。URLhttp://localhost:3000/test?name=%3Cimg%20src=x%20onerror=%27alert(1)%27%20/%3Eにアクセスすると、関数は入力をエスケープします。しかし、別のURL(括弧[]に注目)http://localhost:3000/test?name[]=%3Cimg%20src=x%20onerror=%27alert(1)%27%20/%3Eにアクセスすると、XSSを引き起こすことができます。req.query.nameパラメーターが配列として解析され、エスケープされないためです。

修正方法

Snykは2021年9月1日にメンテナーへ非公開で連絡し、この問題はv5.3.2で速やかに修正されました。導入された修正では、SafeValue型以外の入力をすべてエスケープすることで、このケースに対処しています。

export function escape(input: any): string {
  return input instanceof SafeValue ? input.value : string.escapeHTML(String(input))
}

このような状況を防ぐ別の方法として、エスケープする前に入力を文字列に変換する(上記の例のように入力の型を確認せずに済む)方法や、入力が文字列型でない場合に空文字列(またはエラーメッセージ)を返す方法があります。

対象者別のポイント

開発者

サニタイズに===演算子を使う場合は、両方のオペランドが同じ型であることを確認することが重要です。入力をエスケープする場合は、入力が文字列型でないケースにも対処する必要があります(特に、文字列以外の値に対する関数の挙動が文書化されていない場合)。

メンテナー

関数やAPIが入力のサニタイズを担う場合、入力の型に対応していないのであれば、ユーザーの混乱を避けるため、その挙動を文書化することが重要です。メンテナーは、文字列の場合の入力エスケープなど、一部のサニタイズの問題には対処していても、入力が文字列でない場合への対応は行っていないことがあります。

セキュリティ研究者

今回はプロトタイプ汚染とXSSの脆弱性に注目しましたが、この攻撃手法によって既存のサニタイズを回避し、セキュリティ上の問題につながる別のケースやシナリオも存在すると考えています。弊社プログラムの対象となっているオープンソースプロジェクトで同様の問題(またはその他のセキュリティ脆弱性)を見つけた場合は、Snyk Vulnerability Disclosureフォームからぜひご報告ください。

参考資料

CTFを始めよう

オンデマンドのバーチャル入門ワークショップを視聴して、CTFの課題の解き方を学びましょう。

続きを読む

Blog

フロンティアモデルは脆弱性を発見した。攻撃者だけがエクスプロイトチェーンを見つけた。

静的解析で欠陥は見つかりましたが、ライブ攻撃テストで侵害につながる連鎖を実証できたのは唯一でした。Evo COS、Claude Security、Claude Code Securityを比較します。

feature insights context
Blog

自律型攻撃はすでに始まっている。防御もそのスピードに追いつかなければならない。

自律型攻撃者によって、防御に使える時間は短くなっています。継続的な検出、修復、検証、予防で、セキュリティチームが攻撃に歩調を合わせる方法をご紹介します。

Blog

AIコーディングエージェントが不適切なアクセス制御を繰り返し実装する理由

AIコーディングエージェントは、コンパイルが通りレビューも通過する一方で、あるテナントのデータを別のテナントに公開してしまう認可ロジックを生成することがあります。不適切なアクセス制御が検出しにくい理由と、その防止策をご紹介します。