IERAE CTF 2026 - FINAL for Humans / Dopa Secret — writeup https://github.com/tyage/dopa-secret

Dopa Secret — writeup

日本語 | English

Overview

  • The target's color parameter lets us append arbitrary CSS properties to the style attribute of #main (CSS injection). We cannot leave the style attribute, so HTML and scripts cannot be injected.
  • Making #main editable with -webkit-user-modify:read-write makes it focusable. When a cross-origin iframe navigates to the fragment #main, the scroll that accompanies the focus also scrolls the attacker page's scroll container that wraps the iframe. Its scrollLeft gives the width of the first line inside the iframe as a number.
  • Narrowing the iframe by one character at a time shortens the first line by one character each time. We measure the width at every step and take the difference between neighbouring measurements to get each character's width. Matching these against a precomputed table of character widths recovers the 123-letter challenge. We then solve the PoW and POST it.
  • The same CSS can be appended to the POST response (the page that shows the FLAG). We read the FLAG's character widths the same way and decode it by combining widths measured in two different fonts.
  • Because each measurement returns a number (a width) rather than a single yes/no bit, the whole attack fits into the bot's 10 seconds even on the real bot (next section).

Challenge

The source is in target/app.mjs, target/pow.mjs and bot/app.mjs.

Item Details
bot A headless Chromium (--jitless) on http://bot:3000 opens the HTML sent to /visit for 10 seconds. The CPU is limited to one core. The HTML may be at most 10,000 bytes
target http://target:8080/, reachable only from the bot. There is no X-Frame-Options, so it can be framed
GET Issues a challenge of 123 random lowercase letters and shows PoW:
POST Shows the FLAG with status 200 if the challenge and answer are correct, otherwise returns 403
challenge Valid for 10 seconds and can be used only once
PoW An answer (decimal, up to 10 digits) such that the first 10 bits of SHA256(":") are zero
CSP script-src 'none', style-src 'unsafe-inline'. font, img, connect and others are also 'none'

There are three obstacles.

  1. The challenge is shown only on a cross-origin page, so we cannot read its DOM.
  2. Reading the challenge, solving the PoW and POSTing must all finish within the 10 seconds the bot keeps the page open.
  3. Even after a correct POST, the FLAG is again shown on a cross-origin page.

Comparison with reading one bit at a time

For this challenge one could also build conditions in CSS and read one yes/no bit per query. For example:

  • Make focus move into the iframe or not depending on whether the text overflows a given width, and observe it through the parent page's document.activeElement.
  • Use calc-size() or similar to make an element either zero-width or full-width, stack heavy filters on it, and observe the difference in rendering time.

Such approaches are tight on time here. 123 random lowercase letters carry 123 × log2(26) ≈ 578 bits, so at one bit per query at least about 580 queries are needed. Even using all 10 seconds leaves only about 17 ms per query, and in that time the long text inside the iframe has to be laid out again and the result observed from the parent page. Time also has to be left for the PoW and the POST. A fast PC may make it, but the real bot (Chromium limited to one CPU core on a GCP e2 instance) has no margin. The rendering-time approach puts far too much load on the bot to begin with.

This solution gets a number, the width, from each measurement. Almost every character has a different width, so one measurement reads one character (up to about 4.7 bits). That is why 123 characters can be read in 124 measurements. Characters that widths alone cannot tell apart (a/v and o/q) are handled by POSTing several candidates (sections 3 and 4).

How the attack works

Every time the bot opens the payload, the following happens. server.py is a server the attacker runs locally. It serves the payload, does the heavy computation, and keeps measurements across bot visits.

Attacker page (Chromium in the bot)                    server.py
───────────────────────────────────                  ─────────────────────────
1. GET the target in an iframe (issues a challenge)
2. Measure text widths 124 times ── widths ──▶      3. Match against the width
   (about 4.2 s)                  ◀── candidates ──    table, return up to 64
                                                       challenge candidates
                                  ── candidates ──▶ 4. Solve the PoW for each
                                  ◀── answers ────     candidate in parallel
5. POST candidates and answers, 20 at a time.
   The target that returned 200 holds the FLAG page
6. Measure widths on the FLAG page ── widths ──▶    7. Accumulate measurements
                                                       across visits and
                                                       decode the FLAG

If the correct challenge is not among the up to 64 candidates returned by server.py, that visit cannot reach the FLAG page. The next visit gets a new challenge, so we keep making the bot visit until the FLAG is obtained.

1. CSS injection

The target embeds the color query parameter like this:

`${content}`

escapeHtml converts & " < >, so we cannot leave the style attribute and cannot inject HTML or scripts. ; is not converted, though, so ending the color value with ; lets us append any CSS properties after it. The length limit is 100 characters. The CSP allows the style attribute.

color is read from the URL query for both GET and POST. Adding ?color=... to the POST target URL therefore applies the same CSS to the response that shows the FLAG.

To measure the challenge, we pass exactly these 100 characters as color:

;line-break:anywhere;text-align:end;-webkit-user-modify:read-write;font:small-caps 9in serif;zoom:99
Property Purpose
line-break:anywhere Allows a line break between any two characters. A 123-letter string with no spaces would normally not wrap
text-align:end Right-aligns lines, so the left edge of a line is at "viewport width − line width"
-webkit-user-modify:read-write Makes #main editable and therefore focusable. Without it the parent page does not scroll (section 2.1)
font:small-caps 9in serif Makes the text large. With small-caps, fewer pairs of letters share the same width (section 5.2)
zoom:99 Enlarges it further. The zoomed font size hits Chromium's limit (10000px), and each character becomes about 2,700–7,200px wide

We confirmed the effect of zoom from the precomputed widths. One lowercase a is about 3494px wide with zoom:8 (font size 6912px) and about 5055px with zoom:99. The ratio is 1.447, which equals 10000/6912, so the size is capped at the limit.

On the FLAG page we switch between these two, with zoom:99 changed to zoom:8 (section 5):

;line-break:anywhere;text-align:end;-webkit-user-modify:read-write;font:small-caps 9in serif;zoom:8
;line-break:anywhere;text-align:end;-webkit-user-modify:read-write;font:9in serif;zoom:8

2. Measurement: navigating to a fragment scrolls the parent page

2.1 Why the parent page scrolls

When the part of the URL after # changes to #main, the browser scrolls until the element with id="main" is visible, and moves focus to it if it is focusable. In Chromium, an ordinary fragment scroll does not scroll a cross-origin parent page. However, the scroll that accompanies focus does scroll the cross-origin parent page too (AllowedToPropagateToParent() in Blink's scroll_into_view_util.cc). The attacker page's scroll container around the iframe therefore moves, and the attacker can read its scrollLeft.

#main is a plain div, so it is not focusable, and on its own the parent page does not scroll. Making it editable with -webkit-user-modify:read-write makes #main focusable, and the parent page scrolls. This seemingly unrelated property is what makes the attack possible.

When focus moves to an editable element, the caret is placed at the start of its content and the page scrolls so the caret is visible. Because the text is right-aligned with text-align:end, the caret's x coordinate (the left edge of the first line) is W − first-line width, where W is the iframe width. scrollLeft is therefore a linear function of the first line's width.

Even for a cross-origin frame, the URL can be changed with contentWindow.location.replace(). Navigating to a URL that differs only after # does not reload the page. A page opened with GET does not issue a new challenge, and a POST response is not resubmitted. The same page can therefore be measured any number of times.

2.2 Elements on the attacker page

The attacker page builds this structure:

  • At scroll position (0,0) the iframe is completely outside box, so scrollLeft ≠ 0 tells whether a scroll happened.
  • transform:scale(S,1) stretches the iframe horizontally by a factor of S. scrollLeft is an integer, so widths can be measured in units of 1/S px. Each challenge character is thousands of pixels wide, so S=1 is enough. For the FLAG we use S=16 to measure in 1/16 px units.
  • The iframe width W becomes the viewport width seen by the target and decides where lines wrap.

2.3 Measuring a width once

1. Set the iframe width to W
2. location.replace(base + "#header")   // take focus away from #main
3. box.scrollTo(0, 0)
4. location.replace(base + "#main")     // focus #main → the parent scrolls too
5. Wait until scrollLeft is no longer 0, then read it

#header is not focusable. Navigating there first takes focus away from #main, so every navigation to #main focuses and scrolls again. The value read is converted to a width with:

first-line width = W − (scrollLeft − C) / S

C is a constant determined by how the iframe is placed. It is found by measuring when the first line is a known string. With W = ceil(width of "PoW:"), the first line is exactly PoW:, and its width gives C. The width of PoW: is obtained by placing the same string with the same CSS on the attacker page and measuring it. The target page and the attacker page are rendered by the same Chromium in the bot, so fonts and widths match exactly.

2.4 Measuring while shortening the text one character at a time

With line-break:anywhere, the first line holds as many characters as fit in width W. We use this to measure the first line while removing one character at a time from its end. Below, w(k) is the width of PoW: followed by the first k characters of the challenge. For example, if the challenge is xyz…, w(2) is the width of PoW: xy.

  1. Start with W wide enough for all of PoW: to fit on one line. This gives w(123).
  2. Set the next W to "the width just measured − 4px". Only the last character no longer fits, so the first line becomes one character shorter. Every character is at least 2,700px wide, so narrowing by 4px never removes two characters.
  3. Repeat until w(1) has been measured.

One measurement to find C plus 123 measurements from w(123) to w(1) makes 124 in total. On the challenge server's bot this took about 4.2 seconds.

3. Recovering the challenge

From here on, server.py does the computation.

The difference w(k) − w(k−1) is the width added by the k-th character, i.e. the width of the k-th character. The same character's width varies slightly depending on the preceding characters (kerning and rounding). So we precompute a table of the widths of every 3-character string and every 4-character string formed by adding one more character, for all combinations (493,533 entries, including combinations that contain the end of PoW: ). They were measured on a canvas in the same Chromium as the bot container (Chrome 154 at the time), using small-caps 891in serif, which gives the same size. With the three preceding characters ctx, the width added by character c is predicted as width(ctx + c) − width(ctx).

  • Dynamic programming: the state is "the three preceding characters". Going from the first character onward, we keep only characters whose predicted width is within the tolerance of the measurement. The tolerance is widened in the order 1.1, 4.1, 16.1, 32.1, 64.1 px until some character matches. States with the same three preceding characters are merged, while counting how many strings lead to each state.
  • Positions with bad measurements: a position that matches no character is skipped without deciding a character (at most two positions). Nearby positions are sent back to the browser to be measured again (up to 4 positions at a time, at most twice).
  • Characters that cannot be told apart: in small-caps, a/v and o/q have the same width in most cases. Kerning distinguishes them after some preceding characters, but many candidates remain. We return at most 64 candidates, ordered by how small their error against the prediction is. If the correct challenge is not among them, that visit fails. This is the main reason the time to get the FLAG varies.

The PoW is 10 bits, so each candidate needs about 1,024 hash computations on average. The bot is slow at computation because of --jitless, so server.py solves them in parallel with 8 processes and returns the answers.

4. POSTing candidates to reach the FLAG page

  • Create a form whose action is http://target:8080/?color=&nonce=...#main, set its target attribute to the name of an , and POST. The color in the query applies the CSS to the FLAG page as well.
  • We use rather than because an fires an error event on an HTTP error (403) and a load event on 200. An iframe fires load in both cases, so we could not tell which candidate succeeded.
  • Twenty elements are prepared in advance with about:blank, and 20 candidates are sent at once. If all 20 fire error, new elements are created and the next 20 are sent. The first that fires load holds the FLAG page.
  • That is wrapped in the same structure as in section 2.2 (a spacer element and a parent element stretched with scale) and used to measure the FLAG. The challenge is already used and the POST cannot be repeated, so this page is the only one we can measure. Changing only the part after # does not resubmit the form.

5. Recovering the FLAG

Measuring the FLAG page is harder than the challenge. Each successful POST yields only one page, the FLAG length is unknown, and only a few seconds of the visit remain. The solver assumes the FLAG has the form IERAE{[a-z]+}.

5.1 First successful POST: measure the whole FLAG in small-caps

We measure with zoom:8 and S=16, i.e. in 1/16 px units (values are sent to server.py as integers multiplied by 16).

  1. Start with a wide W so that all of IERAE{...} fits on one line.
  2. Measure with W 512px or 2048px narrower than the last measured width. If the decrease matches the width of some single character in the table within ±2.1 px, the first line is taken to be one character shorter. Otherwise measure again with another W.
  3. Stop when the first line's width is almost equal to the width of IERAE{. The number of steps taken gives the length of the FLAG body.

After each character, the measurement is saved to server.py with a keepalive fetch. If the visit ends midway, the next visit with a successful POST measures again from the start. The scroll origin shifts when the that received the POST changes, so measurement does not resume midway.

server.py uses dynamic programming over the precomputed width table to rank FLAG candidates by how well they fit the measurements. The table contains the width of the character right after IERAE{ and the width of each character (26) after each preceding character (26). In small-caps, however, a/v and o/q have the same width at some positions, so the FLAG cannot be decided yet.

5.2 Second successful POST: measure only the positions needed to separate candidates, in plain lowercase

The two fonts have different sets of letters that share a width. The table below is for letters after z. In lowercase, these groups have the same width regardless of the preceding character.

Font Letters with the same width (after z)
small-caps serif a/v, o/q
plain lowercase serif b/d/g/p/q, h/n/u, i/l, v/y

In lowercase, a and v, and o and q, have different widths, so letters that small-caps could not tell apart can be told apart.

  1. server.py picks, one at a time, the character position that best separates the remaining candidates at that point (up to 8 positions). The width of the i-th character is w(i) − w(i−1), so at most 16 widths are measured.
  2. The browser predicts the needed w(i) from the current best candidate and measures only that width, with W 512px narrower than the prediction.
  3. If a value matches no character width, it is measured again on the spot. Measurements are kept across visits.

FLAG: ... is printed when all three of the following hold:

  • The candidate explains every width measured in small-caps within the tolerance.
  • The widths measured in lowercase also match the same candidate.
  • The total error against the measurements differs by at least a fixed margin between the best and second-best candidates.

Getting the FLAG therefore needs at least two visits with a successful POST.

6. Implementation details

  • Payload size: the bot accepts at most 10,000 bytes of HTML. server.py concatenates the four JS files, gzips them, and embeds them in the HTML as base64url. The page decompresses them with DecompressionStream and runs them. If that still does not fit, it switches to loading the JS from server.py.
  • Time limits: the bot's 10 seconds start right after it opens the page. POSTing stops 9.6 seconds after the page opens, and FLAG measurement stops at 9.9 seconds.
  • Splitting the heavy work: computing challenge candidates, the PoW, and decoding the FLAG are all done by server.py. The JS running in the bot only measures widths and POSTs.
  • Frame load failures: if the target iframe does not load within 500 ms, it is recreated (up to 3 times).

Files

File Contents
exploit.html Payload template. server.py replaces the script tag with the compressed, embedded JS
exploit.js The flow of one visit (measure the challenge → POST → measure the FLAG)
multi-frame-reader.js Width measurement (a single measurement, and repeated measurement while shortening the text one character at a time)
text-width-reader.js Measures widths of known strings on the attacker page
config.js The CSS passed as color, measurement parameters, and the FLAG width tables (per preceding character)
server.py Serves the payload, computes challenge candidates and PoWs, stores FLAG measurements and decodes the FLAG
challenge-metrics-canvas.json Character width table for the challenge (per three preceding characters)
flag-metrics-dom.json / flag-metrics-lower-dom.json Character width tables for the FLAG (small-caps / plain lowercase)
run-remote.sh Keeps sending the payload to the bot until the FLAG is obtained

The width tables were built with the same Chromium (Debian's chromium, Chrome 154 at the time) and fonts as the bot container. If the bot's Chromium version or fonts change, they must be rebuilt.

Reproducing

server.py needs a URL that the bot can reach, so we use a Cloudflare Quick Tunnel here. Install Python 3 and cloudflared, then run the following in this directory, each in its own terminal.

# 1. Get a URL through which the bot can reach server.py
cloudflared tunnel --url http://127.0.0.1:4000

# 2. Start server.py with the HTTPS URL printed in step 1
python3 server.py 4000 https://EXAMPLE.trycloudflare.com

# 3. Keep sending the payload to the bot until the FLAG is obtained
./run-remote.sh http://127.0.0.1:4000 http://BOT_HOST:PORT 600

The arguments of run-remote.sh are the local server.py URL, the bot URL, and the maximum run time in seconds. At start it clears the FLAG measurements stored in server.py. Set KEEP_STATE=1 only to continue an interrupted attempt against the same FLAG.

server.py exposes these paths for checking progress:

Path Contents
/last The latest result. On success, FLAG: IERAE{...}
/state Number of stored measurements, the font for the next visit, and positions not yet measured
/debug-log Recent challenge, POST and FLAG measurement logs
/reset Clears the stored FLAG measurements

Stop server.py and the tunnel when done.

Results

With a 123-letter challenge and a bot that keeps the page open for 10 seconds, we cleared the stored measurements before each attempt. Times are from starting run-remote.sh until the FLAG was obtained, not including setting up server.py and the tunnel.

Attempt Bot visits Time to FLAG
1 23 258 s
2 27 about 305 s
3 7 78 s
4 26 292 s

The 124 measurements needed to read the challenge took about 4.2 seconds. The total time varied mainly with how many visits it took until the correct challenge was among the 64 candidates returned by server.py.

Dopa Secret — writeup

日本語 | English

概要

  • targetの color パラメータを使って、#main の style 属性に任意のCSSプロパティを書き足せる(CSSインジェクション)。style 属性の外には出られないので、HTMLやスクリプトは埋め込めない。
  • -webkit-user-modify:read-write で #main を編集可能にすると、#main にフォーカスできるようになる。別オリジンのiframeをページ内リンク #main へ移動させると、フォーカスに伴うスクロールで、iframeを囲む攻撃者ページ側のスクロール領域もスクロールする。その scrollLeft から、iframe内の1行目の幅を数値で読める。
  • iframeの幅を1文字分ずつ狭めると、1行目に収まる文字列が1文字ずつ短くなる。そのたびに幅を測り、隣り合う測定値の差から各文字の幅を求める。これを事前に計算しておいた文字幅の表と照合し、123文字のchallengeを復元する。PoWを解いてPOSTする。
  • POSTのレスポンス(FLAGを表示するページ)にも同じCSSを書き足せる。同じ方法でFLAGの文字幅を読み、2種類のフォントで測った幅を組み合わせて復号する。
  • 1回の測定で読めるのが「はい/いいえ」の1 bitではなく幅という数値なので、本番のbotでも10秒に間に合う(次節)。

問題

仕様は target/app.mjs、target/pow.mjs、bot/app.mjs にある。

項目 内容
bot /visit に送ったHTMLを、http://bot:3000 上のheadless Chromium(--jitless)が10秒間開く。CPUは1コアに制限されている。送れるHTMLは10,000バイトまで
target http://target:8080/。botからのみ到達できる。X-Frame-Options が無いのでiframeに入れられる
GET 123文字のランダムな小文字の challenge を発行し、PoW: を表示する
POST challengeとanswerが正しければ200でFLAGを表示し、それ以外は403を返す
challenge 有効期限は10秒。1回使うと無効になる
PoW SHA256(":") の先頭10 bitが0になるanswer(10桁以内の10進数)
CSP script-src 'none'、style-src 'unsafe-inline'。font、img、connectなども 'none'

解くうえでの障害は3つある。

  1. challengeは別オリジンのページにしか表示されず、DOMを読めない。
  2. challengeの読み取り、PoW、POSTを、botがページを開いている10秒の中で終える必要がある。
  3. 正しくPOSTしても、FLAGが表示されるのはやはり別オリジンのページである。

1 bitずつ読む方法との比較

この問題では、CSSで条件を作り、問い合わせ1回につき「はい/いいえ」の1 bitずつ読む方法も考えられる。たとえば次のような方法である。

  • 文字列が指定した幅からあふれたかどうかでiframeにフォーカスが移るかを変え、親ページの document.activeElement で観測する。
  • calc-size() などで要素の幅を0か全幅のどちらかに振り分け、重いフィルタを重ねて、描画にかかる時間の差として観測する。

この問題では、こうした方法は時間の面で厳しい。ランダムな小文字123文字の情報量は 123 × log2(26) ≈ 578 bit なので、1回に1 bitしか得られなければ、少なくとも約580回の問い合わせが要る。10秒をすべて使っても1回あたり約17 msしかなく、その間にiframe内の長いテキストを配置し直し、結果を親ページで観測しなければならない。PoWやPOSTに使う時間も残す必要がある。速いPCなら間に合うこともあるが、CPUを1コアに制限された本番のbot(GCPのe2インスタンス上で動く)では余裕がない。描画時間の差を使う方法は、そもそもbotにかかる負荷が大きすぎる。

このsolutionでは、1回の測定で幅という数値が得られる。幅は1文字ごとにほぼ異なる値をとるので、1回の測定で1文字分(最大で約4.7 bit)を読める。123文字を124回の測定で読めるのはこのためである。幅だけでは区別できない文字(a/v と o/q)は、候補を複数POSTすることで補う(3節、4節)。

攻撃の全体像

botがpayloadを1回開くたびに、次の処理を行う。server.py は攻撃者が手元で動かすサーバで、payloadの配信、重い計算、botの訪問をまたいだ測定結果の保存を担当する。

攻撃者ページ (bot内のChromium)                          server.py
───────────────────────────────                      ─────────────────────────
1. targetをiframeでGET(challenge発行)
2. 文字列の幅を124回測る(約4.2秒)── 測った幅 ──▶  3. 文字幅の表と照合し、
                                   ◀── 候補 ────       challengeの候補を最大64個返す
                                   ── 候補 ───▶    4. 各候補のPoWを並列計算
                                   ◀── answer ──
5. 候補とanswerを20件ずつPOST
   200が返った送信先がFLAGのページ
6. FLAGのページの幅を測る           ── 測った幅 ──▶  7. 訪問をまたいで測定結果をためて
                                                      FLAGを復号

server.py が返す最大64個の候補に正しいchallengeが含まれていなければ、その訪問ではFLAGのページに到達できない。次の訪問では新しいchallengeが発行されるので、FLAGが得られるまでbotに何度も訪問させる。

1. CSSインジェクション

targetはクエリパラメータ color を次のように埋め込む。

`${content}`

escapeHtml は & " < > を変換するので、style 属性の外には出られず、HTMLやスクリプトは埋め込めない。一方 ; は変換されないので、; で color の値を終わらせれば、その後ろに任意のCSSプロパティを書き足せる。長さの上限は100文字である。CSPは style 属性の使用を許可している。

color はGETでもPOSTでもURLのクエリから読まれる。そのため、POSTの送信先URLに ?color=... を付ければ、FLAGを表示するレスポンスにも同じCSSが適用される。

challengeを測るときに color に渡すのは、次のちょうど100文字である。

;line-break:anywhere;text-align:end;-webkit-user-modify:read-write;font:small-caps 9in serif;zoom:99
プロパティ 役割
line-break:anywhere 任意の文字の間で折り返せるようにする。空白のない123文字の英字列は、通常は折り返されない
text-align:end 行を右寄せにする。行の左端の位置が「表示領域の幅 − 行の幅」になる
-webkit-user-modify:read-write #main を編集可能にし、フォーカスできるようにする。これが無いと親ページはスクロールしない(2.1節)
font:small-caps 9in serif 文字を大きくする。small-capsを使うと、幅が同じになる文字の組が少なくなる(5.2節)
zoom:99 さらに拡大する。zoomを掛けた後のフォントサイズはChromiumの上限(10000px)に達し、1文字あたり約2,700〜7,200pxになる

zoomの効果は、事前に計算した文字幅から確かめた。小文字 a 1文字分の幅は、zoom:8(フォントサイズ6912px)で約3494px、zoom:99 で約5055pxである。比は1.447で、10000/6912と一致するので、上限で頭打ちになっていることが分かる。

FLAGのページでは、zoom:99 を zoom:8 に変えた次の2つを使い分ける(5節)。

;line-break:anywhere;text-align:end;-webkit-user-modify:read-write;font:small-caps 9in serif;zoom:8
;line-break:anywhere;text-align:end;-webkit-user-modify:read-write;font:9in serif;zoom:8

2. 測定の仕組み:ページ内リンクの移動で親ページがスクロールする

2.1 親ページがスクロールする理由

URLの # 以降を #main に変えてページ内リンクで移動すると、ブラウザは id="main" の要素が見える位置までスクロールし、その要素にフォーカスできればフォーカスを移す。Chromiumでは、ページ内リンクによる普通のスクロールでは、別オリジンの親ページはスクロールしない。しかしフォーカスに伴うスクロールでは、別オリジンの親ページもスクロールする(Blinkの scroll_into_view_util.cc にある AllowedToPropagateToParent())。このため、iframeを囲む攻撃者ページのスクロール領域も動き、攻撃者はその scrollLeft を読める。

#main はただの div なのでフォーカスできず、そのままでは親ページはスクロールしない。-webkit-user-modify:read-write で編集可能にすると、#main にフォーカスできるようになり、親ページまでスクロールする。一見無関係なこのプロパティが、攻撃の前提条件になっている。

編集可能な要素にフォーカスが移ると、入力カーソル(キャレット)が内容の先頭に置かれ、そのカーソルが見えるようにスクロールされる。text-align:end で右寄せされているので、カーソルのx座標(1行目の左端)は W − 1行目の幅 になる(W はiframeの幅)。したがって scrollLeft は1行目の幅の一次関数になる。

別オリジンのフレームでも、URLは contentWindow.location.replace() で変えられる。# 以降だけが違うURLへの移動では、ページは読み込み直されない。GETで開いたページなら新しいchallengeは発行されず、POSTのレスポンスなら再送信は起きない。このため、同じページを何度でも測れる。

2.2 測定のために攻撃者ページに作る要素

攻撃者ページには次の構造を作る。

  • スクロール位置が (0,0) のとき、iframeは box から完全に見えない位置にある。そのため、スクロールが起きたかどうかを scrollLeft ≠ 0 で判定できる。
  • transform:scale(S,1) でiframeを横方向だけ S 倍に拡大する。scrollLeft は整数なので、幅は 1/S px単位で測れる。challengeは1文字が数千pxあるので S=1 で足りる。FLAGでは S=16 にして1/16 px単位で測る。
  • iframeの幅 W が、target側から見た表示領域の幅になり、どこで折り返すかを決める。

2.3 幅を1回測る手順

1. iframeの幅をWにする
2. location.replace(base + "#header")   // #mainからフォーカスを外す
3. box.scrollTo(0, 0)
4. location.replace(base + "#main")     // #mainにフォーカス → 親までスクロール
5. scrollLeftが0でなくなるまで待って読む

#header にはフォーカスできない。一度そこへ移動して #main からフォーカスを外しておくと、次に #main へ移動したとき、毎回フォーカスとスクロールが起き直る。読み取った値は次の式で幅に戻す。

1行目の幅 = W − (scrollLeft − C) / S

C は、iframeの配置などで決まる定数である。1行目が既知の文字列になるようにして測れば求められる。W = ceil(「PoW:」の幅) にすると1行目がちょうど PoW: になるので、その幅から C が決まる。PoW: の幅は、攻撃者ページ上に同じCSSで同じ文字列を置いて測れば分かる。targetのページも攻撃者ページも同じbotのChromiumが描画するので、フォントも幅も完全に一致する。

2.4 文字列を1文字ずつ短くしながら幅を測る

line-break:anywhere があるので、1行目には「幅 W に収まる範囲で、できるだけ多くの文字」が入る。これを使って、1行目の文字列を末尾から1文字ずつ短くしながら測る。以下では、PoW: にchallengeの最初のk文字をつないだ文字列の幅を w(k) と書く。たとえばchallengeが xyz… なら、w(2) は PoW: xy の幅である。

  1. 最初は W を十分に広くし、PoW: 全体を1行に収める。これで w(123) が分かる。
  2. 次の W は「直前に測った幅 − 4px」にする。最後の1文字だけが収まらなくなり、1行目が1文字短くなる。1文字は2,700px以上あるので、4px狭めても2文字以上減ることはない。
  3. w(1) を測るまで繰り返す。

C を求める1回と、w(123) から w(1) までの123回で、合計124回測る。問題サーバのbotでは約4.2秒かかった。

3. challengeの復元

ここからの計算は server.py が行う。

差 w(k) − w(k−1) は、k文字目を足したことで増えた幅、つまりk文字目の幅である。ただし、同じ文字でも直前の文字によって幅がわずかに変わる(カーニングや丸めの影響)。そこで、直前の3文字と次の1文字のすべての組み合わせについて、3文字の文字列と、1文字足した4文字の文字列の幅を、事前に測って表にしておく(PoW: の末尾を含む組み合わせも含め、計493,533件)。測定には、botのコンテナと同じChromium(作成時はChrome 154)のcanvasで、同じ大きさになる small-caps 891in serif を使った。直前の3文字を ctx とすると、文字 c を足したときに増える幅は 幅(ctx + c) − 幅(ctx) で予測できる。

  • 動的計画法:「直前の3文字」を状態とし、先頭から1文字ずつ、予測と測定値の差が許容誤差に収まる文字だけを残していく。許容誤差は1.1、4.1、16.1、32.1、64.1 pxの順に、当てはまる文字が見つかるまで広げる。直前の3文字が同じになった状態は1つにまとめ、その状態に至る文字列が何通りあるかを数えておく。
  • 測定値がおかしい位置:どの文字とも一致しない位置は、文字を決めずに読み飛ばす(最大2か所)。その付近の位置をブラウザへ返して測り直させる(1回に最大4か所、最大2回)。
  • 区別できない文字:small-capsでは a/v と o/q がほとんどの場合同じ幅になる。直前の文字によってはカーニングで区別できるが、多くの候補が残る。予測との誤差が小さい順に最大64個の候補を返す。正しいchallengeがこの中に無ければ、その訪問は失敗になる。FLAGを得るまでの時間がばらつく主な原因はここにある。

PoWは10 bitなので、1つの候補につき平均約1,024回のハッシュ計算で解ける。botは --jitless で計算が遅いため、server.py が8プロセスで並列に計算して返す。

4. 候補をPOSTしてFLAGのページを得る

  • 送信先を http://target:8080/?color=&nonce=...#main としたformを作り、target 属性に の名前を指定してPOSTする。クエリの color によって、FLAGのページにもCSSが適用される。
  • ではなく を使うのは、 では、HTTPエラー(403)なら error イベント、200なら load イベントが起きるからである。iframeではどちらの場合も load イベントが起きるので、どの候補が通ったか分からない。
  • あらかじめ20個の を about:blank で用意し、20個の候補をまとめて送る。20件すべてで error が起きたら、新しい を作って次の20件を送る。最初に load が起きた に、FLAGのページが読み込まれている。
  • その を2.2節と同じ構造(余白用の要素と、scale で拡大する親要素)で包み直し、FLAGの測定に使う。challengeは使用済みでPOSTをやり直せないので、このページを測るしかない。# 以降を変えるだけなら再送信は起きない。

5. FLAGの復元

FLAGのページの測定はchallengeより難しい。POSTが通るたびに手に入るページは1つだけで、FLAGの長さも分からず、その訪問の残り時間は数秒しかない。solverはFLAGの形式を IERAE{[a-z]+} と仮定している。

5.1 1回目にPOSTが通ったとき:small-capsでFLAG全体を測る

zoom:8、S=16 で、1/16 px単位で測る(server.py へは16倍した整数で送る)。

  1. 最初は W を広くし、IERAE{...} 全体を1行に収める。
  2. 直前に測った幅より512pxまたは2048px狭い W で測る。幅の減り方が、表にあるいずれかの文字1文字分の幅と±2.1 pxで一致すれば、1行目が1文字短くなったとみなす。一致しなければ別の W で測り直す。
  3. 1行目の幅が IERAE{ の幅とほぼ等しくなったら終える。それまでに短くした回数から、FLAG本文の長さが分かる。

1文字分測るたびに、keepalive 付きのfetchで server.py に保存する。途中で訪問が終わった場合は、次にPOSTが通った訪問で最初から測り直す。POSTを受けた が変わるとスクロール位置の基準がずれるので、途中からは再開しない。

server.py は、事前に測っておいた文字幅の表を使った動的計画法で、FLAGの候補を測定値によく合う順に並べる。この表には、IERAE{ の直後に来る文字の幅と、直前の文字(26通り)ごとの各文字(26通り)の幅が入っている。ただし、small-capsでは a/v、o/q が同じ幅になる位置があるので、ここではまだFLAGを確定できない。

5.2 2回目にPOSTが通ったとき:通常の小文字で、候補の区別に必要な位置だけ測る

2つのフォントでは、同じ幅になる文字の組が異なる。下の表は直前の文字が z の場合である。小文字の方の組は、直前の文字によらず同じ幅になる。

フォント 幅が同じになる文字(z の直後)
small-caps serif a/v、o/q
通常の小文字 serif b/d/g/p/q、h/n/u、i/l、v/y

小文字では a と v、o と q の幅が違うので、small-capsで区別できなかった文字を区別できる。

  1. server.py は、残った候補どうしを最もよく区別できる文字の位置を、その時点で一番よいものから1か所ずつ選ぶ(最大8か所)。i文字目の幅は w(i) − w(i−1) なので、測る幅は最大16個になる。
  2. ブラウザは、現時点で最も有力な候補を使って必要な w(i) を予測し、それより512px狭い W で、その幅だけを測る。
  3. どの文字の幅とも一致しない値が出たら、その場で測り直す。測った値は訪問をまたいで保存する。

次の3つがすべて成り立ったときに FLAG: ... を出力する。

  • 候補が、small-capsで測ったすべての幅を許容誤差内で説明できる。
  • 小文字で測った幅も、同じ候補と一致する。
  • 最も有力な候補と2番目の候補で、測定値との誤差の合計に一定以上の差がある。

したがって、FLAGを得るにはPOSTが通る訪問が少なくとも2回必要である。

6. 実装上の工夫

  • payloadのサイズ:botが受け付けるHTMLは10,000バイトまでである。server.py は4つのJSファイルを連結してgzipし、base64urlでHTMLに埋め込む。ページ側は DecompressionStream で展開して実行する。それでも収まらない場合は、JSを server.py から読み込む形に切り替える。
  • 時間の制約:botの10秒は、ページを開いた直後から数え始める。POSTは開いてから9.6秒まで、FLAGの測定は9.9秒までで打ち切る。
  • 重い処理の分担:challengeの候補計算、PoW、FLAGの復号はすべて server.py が行う。bot内で動くJSは、幅の測定とPOSTだけを担当する。
  • フレームの読み込み失敗:targetのiframeが500 ms以内に読み込まれなければ作り直す(最大3回)。

ファイル構成

ファイル 内容
exploit.html payloadのひな形。server.py がscriptタグを、圧縮して埋め込んだJSに置き換える
exploit.js 1回の訪問での処理の流れ(challengeの測定 → POST → FLAGの測定)
multi-frame-reader.js 幅の測定(1回の測定と、1文字ずつ短くしながらの連続測定)
text-width-reader.js 攻撃者ページ上で既知の文字列の幅を測る
config.js color に渡すCSS、測定のパラメータ、FLAG用の文字幅の表(直前の文字ごと)
server.py payloadの配信、challengeの候補計算、PoW、FLAGの測定結果の保存と復号
challenge-metrics-canvas.json challenge用の文字幅の表(直前の3文字ごと)
flag-metrics-dom.json / flag-metrics-lower-dom.json FLAG用の文字幅の表(small-caps / 通常の小文字)
run-remote.sh FLAGが得られるまでbotにpayloadを送り続ける

文字幅の表は、botのコンテナと同じChromium(Debianの chromium、作成時はChrome 154)とフォントで作った。botのChromiumのバージョンやフォントが変わった場合は、作り直す必要がある。

再現方法

botから server.py へ届くURLが必要なので、ここではCloudflare Quick Tunnelを使う。Python 3と cloudflared を用意し、このディレクトリで次を別々のターミナルから実行する。

# 1. botから server.py へ届くURLを発行する
cloudflared tunnel --url http://127.0.0.1:4000

# 2. 1で表示されたHTTPSのURLを指定して server.py を起動する
python3 server.py 4000 https://EXAMPLE.trycloudflare.com

# 3. FLAGが得られるまでbotへpayloadを送り続ける
./run-remote.sh http://127.0.0.1:4000 http://BOT_HOST:PORT 600

run-remote.sh の引数は、手元の server.py のURL、botのURL、最大実行秒数の順である。開始時に、server.py に保存されたFLAGの測定結果を消去する。同じFLAGに対して、中断した試行を続けるときだけ KEEP_STATE=1 を付ける。

server.py は、状況確認のために次のパスを持つ。

パス 内容
/last 最新の結果。成功時は FLAG: IERAE{...}
/state 保存した測定結果の数、次の訪問で使うフォント、まだ測っていない位置
/debug-log 直近のchallenge、POST、FLAGの測定のログ
/reset 保存したFLAGの測定結果を消去する

終了後は server.py とトンネルを停止する。

実測

challengeが123文字で、botがページを10秒間開く環境で、保存済みの測定結果を毎回消去してから試した。時間は run-remote.sh を起動してからFLAGを得るまでで、server.py とトンネルの準備時間は含まない。

試行 botの訪問回数 FLAG取得まで
1 23 258秒
2 27 約305秒
3 7 78秒
4 26 292秒

challengeを読むための124回の測定には、約4.2秒かかった。総時間の差は主に、正しいchallengeが server.py の返す64個の候補に入るまでに、何回訪問が必要だったかによる。

Dopa Secret — writeup

This file has been truncated, but you can view the full file.

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论