🤖

Specを読みながらHTMLパーサーの気持ちになってnoscriptタグを解析する

2024/05/22に公開

この話の続き。
https://zenn.dev/kozy4324/articles/135f5dfe5ed0bf

分かっていないこと

  • noscript 閉じタグの扱い、この攻撃例でいう class 属性値の中に出現した場合に異なるパース結果が発生しているがどれが正しい振る舞いなのか
  • document.implementation.createHTMLDocument("").documentElement.innerHTML でパースするのと document.createElement("div").innerHTML でパースするので結果に差異が発生する理由

HTMLのSpecを読みながらHTMLパーサーの気持ちになってnoscriptタグを解析したら理由が分かったのでそのまとめです。

Spec

HTMLのタグ解析について言及されているチャプターのこのセクションからスタート。
https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody

13.2.6.4.7 The "in body" insertion mode

どんなタグの開始タグ及び終了タグが出てきたらどう処理するかというのが条件分岐で記述されている。noscriptタグの開始タグが出現したら以下。

A start tag whose tag name is "noscript", if the scripting flag is enabled
Follow the generic raw text element parsing algorithm.

まずここで scripting flag の分岐があることが分かった。 generic raw text element parsing algorithm の方を見てみる。

13.2.6.2 Parsing elements that contain only text

  1. If the algorithm that was invoked is the generic raw text element parsing algorithm, switch the tokenizer to the RAWTEXT state;

トークナイザーが RAWTEXT state に変わる。また、

  1. Then, switch the insertion mode to "text".

insertion mode も "text" に変わる。

13.2.5.3 RAWTEXT state

トークナイザーの振る舞いで1文字単位でどう処理していくのかの記述がされている。scriptタグの中身とか、HTMLではない文字列を処理するイメージと捉えた(noscriptの中身ってHTMLではなかったのか)。

通常は1文字ずつ、

Anything else
Emit the current input character as a character token.

で consume されていくのだろう。ここで </noscript> が出現したとする。

U+003C LESS-THAN SIGN (<)
Switch to the RAWTEXT less-than sign state.

最初の1文字目の < を受けて、 RAWTEXT less-than sign state に変わる。

13.2.5.12 RAWTEXT less-than sign state

U+002F SOLIDUS (/)
Set the temporary buffer to the empty string. Switch to the RAWTEXT end tag open state.

2文字目の \ で、RAWTEXT end tag open state へ。

13.2.5.13 RAWTEXT end tag open state

ASCII alpha
Create a new end tag token, set its tag name to the empty string. Reconsume in the RAWTEXT end tag name state.

3文字目の n を受けて、RAWTEXT end tag name state へ。

13.2.5.14 RAWTEXT end tag name state

ASCII lower alpha
Append the current input character to the current tag token's tag name. Append the current input character to the temporary buffer.

noscript 部分をこれで受ける。

U+003E GREATER-THAN SIGN (>)
If the current end tag token is an appropriate end tag token, then switch to the data state and emit the current tag token. Otherwise, treat it as per the "anything else" entry below.

</noscript> の最後の > を受けて、 emit the current tag token とあるので、これをもって noscript の閉じタグが出現したと解釈される。

トークナイザーの仕事は一旦終わりで insertion mode の方に戻る。

13.2.6.4.8 The "text" insertion mode

noscript の閉じタグが出てきたので、

Any other end tag
Pop the current node off the stack of open elements.
Switch the insertion mode to the original insertion mode.

ここまでを noscript タグとして処理された。 insertion mode も original insertion mode に戻るので元いた "in body" insertion mode となる。

ここまでのまとめ

  • noscript に対するトークナイザーの処理は RAWTEXT state
  • 「class 属性値の中」という扱いなどないので </noscript> 閉じタグが出現した時点でそこまでが noscript タグと解釈される

以上から、以下のHTML文字列が、

<div>
    <noscript>
        <div class="123</noscript>456<img src=1 onerror=alert(1)//"></div>
    </noscript>
</div>

こう解釈されるのが理解できる。

<div>
    <noscript>&lt;div class="123</noscript>
    456
    <img src=1 onerror=alert(1)//">
</div>

scripting flag

https://html.spec.whatwg.org/multipage/webappapis.html#concept-n-script

Scripting is enabled for a node node if node's node document's browsing context is non-null, and scripting is enabled for node's relevant settings object.

browsing context が non-null で有効(enabled)となる。 browsing context が分からなかったので MDN を参照した。

https://developer.mozilla.org/ja/docs/Glossary/Browsing_context

タブとかウィンドウと言っているが、つまりは browsing context がない場合は JavaScript のグローバルオブジェクトである window がない状態ということらしい(これはAIに聞いた)。なのでスクリプトは実行できない(disabled)状態なんだろう。

別の箇所でも言及があり、
https://html.spec.whatwg.org/multipage/document-sequences.html#concept-document-bc

A Document created using an API such as createDocument() never has a non-null browsing context.

とあるので Trix Editor の件もこれでオチがついた。

まとめ

冒頭の「分かっていないこと」に対する答えが出た。

  • noscript 閉じタグの扱い、この攻撃例でいう class 属性値の中に出現した場合に異なるパース結果が発生しているがどれが正しい振る舞いなのか

scripting flag で分岐する話だったので、どちらも正しい。

  • document.implementation.createHTMLDocument("").documentElement.innerHTML でパースするのと document.createElement("div").innerHTML でパースするので結果に差異が発生する理由

前者は browsing context がないので scripting flag が disabled でのパース。後者は scripting flag が enalbed でのパース。差異の理由は scripting flag にあったということであった。

3行感想

  • 疑問が晴れてスッキリした
  • HTMLパーサーの気持ちになるのなかなかに面白い
  • HTMLパースにおいて scripting flag による分岐があるのは知らなかったし、この話はXSS脆弱性をつく攻撃手法の一つとして覚えておきたい

Discussion