はまやんはまやんはまやん

hamayanhamayan's blog

javascriptパズル (corCTF 2022 friendsより)

CTFでjavascript何もわからなくなったのでパズルとして紹介しておきます。

const x = []
const y = x
['admin','admin','__proto__'] = ['admin','admin','__proto__']
const z = x
['1','2','3'] = ['1','2','3']
console.log(x.includes('admin')) // -> true

このようなコードを書くと最後の行の出力がtrueになるのはなんででしょうか?
(Node.js v16.15.1.で検証済み)  
 
 
 
   
 
 
 
 
 
   
 
 
 
 
 
   
 
解説はもうちょっと下へ  
 
 
 
   
 
 
 
 
 
   
 
 
 
 
 
   
 
 
 
 
 
   
 
 
 
 
 
   
 
 
 
 
 
   
 
 
 
 
 
   
 

解説

解説していきます。
正直、動作ベースで理解している所があって、javascriptに詳しい人に是非直していただきたいです。

コードを再掲すると

const x = []
const y = x
['admin','admin','__proto__'] = ['admin','admin','__proto__']
const z = x
['1','2','3'] = ['1','2','3']
console.log(x.includes('admin')) // -> true

となりますが、このコード、セミコロンが無いんですよね。
無くても動いちゃうのか…
なので行が終わっているように見せかけて実は終わってないということが起きています。
具体的には

const x = [];
const y = x['admin','admin','__proto__'] = ['admin','admin','__proto__'];
const z = x['1','2','3'] = ['1','2','3'];
console.log(x.includes('admin')); // -> true

こんな感じに解釈されます。
const yとconst zの部分の代入はしても使われないので、

const x = [];
x['admin','admin','__proto__'] = ['admin','admin','__proto__'];
x['1','2','3'] = ['1','2','3'];
console.log(x.includes('admin')); // -> true

実質こんな感じになります。
ここで配列にtupleみたいな感じで添え字を指定していますが、最後のものしか使われません。
動作ベースでは最後のものだけでした。なんででしょうね。誰かNode.jsのソースコードベースで教えてほしい。
(追記:@arkark_さんに教えてもらえました!カンマ演算子によって添え字部分が評価された結果の戻り値が最後の部分となるからです。)
つまりこれは

const x = [];
x['__proto__'] = ['admin','admin','__proto__'];
x['3'] = ['1','2','3'];
console.log(x.includes('admin')); // -> true

のような感じに解釈されます。
__proto__については図で理解するJavaScriptのプロトタイプチェーン - Qiitaを見てもらうのが一番分かりやすいと思います。
つまり、要素が無かった時に参照する先なのですが、それを上書きしています。
ちょっとわかりやすくするためにxをダンプしてみましょう。

const x = [];
x['__proto__'] = ['admin','admin','__proto__'];
console.log(x); // -> []
x['3'] = ['1','2','3'];
console.log(x); // -> [ <3 empty items>, [ '1', '2', '3' ] ]
console.log(x.includes('admin')); // -> true

[ <3 empty items>, [ '1', '2', '3' ] ]と出てきましたね。
つまり、[ undefined, undefined, undefined, [ '1', '2', '3' ] ]ということです。
これはx['3']への代入前にxを見てみると[]になることからわかります。
__proto__はいったんおいておいて、xは最初は空の状態なので、そこに3番目に要素を入れると、0,1,2が入っていない状態の配列が出来上がるという訳です。

さて、ここまでくればもうちょっとです。
x.includes('admin')を実行したときには、雰囲気で処理を考えると
x[0]はadminかな?x[1]はadminかな?x[2]はadminかな?というのを確認していくわけですが、 x[0]は参照してもundefinedとなってしまいます。
ですが、ここで__proto__を用意していたことが役立ちます。
要素が無かった時に参照する先なので、x[0]undefinedであれば、x['__proto__'][0]を参照しに行くことになります。
よって、x[0] = x['__proto__'][0] = 'admin'ということになり、結果としてtrueが返されます。

同様にx[1]x[2]__proto__が使われるのですが、そのあたりをわかりやすくしたソースコードが以下です。

const x = [];
x['__proto__'] = ['admin0','admin1','admin2'];
x['3'] = ['1','2','3'];
console.log(x); // -> [ <3 empty items>, [ '1', '2', '3' ] ]
console.log(x[0]); // -> admin0
console.log(x[1]); // -> admin1
console.log(x[2]); // -> admin2
console.log(x[3]); // -> [ '1', '2', '3' ]
console.log(x.includes('admin')); // -> true

emptyのはずなのに具体的に取得すると値が出てくるんですね。恐ろしい。
よって、いろんなことが起こって最終的にadminが含まれていることになってしまう訳です。

終わりに

ほんとに動作ベースでブラックボックス的に理解しただけなので、間違ってたら指摘してもらえると自分が助かります。
良いお盆休みを。