-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The patch is two reorderings\n\nThe vulnerable code in `internal/home/web.go` builds the HTTP server's handler chain by wrapping middleware from the outside in. Pre-patch, in `v0.107.72`:\n\n```go\n// Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies.\nhdlr := h2c.NewHandler(\n withMiddlewares(web.conf.mux, limitRequestBody),\n &http2.Server{},\n)\n\nlogger := web.baseLogger.With(loggerKeyServer, \"plain\")\nlogMw := httputil.NewLogMiddleware(logger, slog.LevelDebug)\nhdlr = logMw.Wrap(hdlr)\n\nweb.httpServer = &http.Server{\n Addr: web.conf.BindAddr.String(),\n Handler: web.auth.middleware().Wrap(hdlr),\n // ...\n}\n```\n\nOutside-in, the chain on the plain-HTTP listener is `auth, log, h2c, mux`.\n\nThe patch (commit `c003e9f9c0`) reorders those four wrappers:\n\n```diff\n- hdlr := h2c.NewHandler(\n- withMiddlewares(web.conf.mux, limitRequestBody),\n- &http2.Server{},\n- )\n+ hdlr := withMiddlewares(web.conf.mux, limitRequestBody)\n ...\n hdlr = logMw.Wrap(hdlr)\n+\n+ hdlr = web.auth.middleware().Wrap(hdlr)\n+\n+ // Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies.\n+ //\n+ // NOTE: The auth middleware must be inside the h2c handler to ensure\n+ // it applies to upgraded HTTP/2 connections as well. See AG-51779.\n+ hdlr = h2c.NewHandler(hdlr, &http2.Server{})\n ...\n web.httpServer = &http.Server{\n ...\n- Handler: web.auth.middleware().Wrap(hdlr),\n+ Handler: hdlr,\n }\n```\n\nOutside-in, the chain is now `h2c, auth, log, mux`. No new logic. No new check. The auth middleware crosses one layer.\n\n## h2c is not a request handler. h2c is a connection handler that pretends to be one.\n\n`golang.org/x/net/http2/h2c.NewHandler` returns an `http.Handler`. Its `ServeHTTP` has two paths.\n\nPath 1 is a regular HTTP/1.1 request with no `Upgrade: h2c` header. The handler delegates to its inner handler and returns. It behaves like any other middleware on a single request.\n\nPath 2 is an HTTP/1.1 request with `Upgrade: h2c` and a valid `HTTP2-Settings` header. The handler hijacks the TCP connection via `http.ResponseWriter.Hijack`, sends `101 Switching Protocols` on the wire, and runs `(&http2.Server{}).ServeConn` on the same socket. `ServeConn` is a blocking call. It reads HTTP/2 frames until the connection closes, and for every stream it sees, it dispatches the resulting request to the same inner handler that was passed to `h2c.NewHandler`.\n\nBoth paths share `ServeHTTP`'s signature. Only one of them is a request. The other is the entire remaining lifetime of the TCP socket, served from inside a single `ServeHTTP` call by an HTTP/2 server the outer `http.Server` can no longer see.\n\nAnything wrapped outside `h2c.NewHandler` runs on path 1 and on the path-2 upgrade request. Once `ServeConn` takes over, the streams it dispatches go straight to the inner handler. The outer middleware was never registered with `http2.Server`. It does not run on the streams. It cannot. They are not requests the outer `http.Server` ever heard about.\n\n## The PoC chooses /login.html for a reason\n\n```python\ns.sendall(\n f\"GET /login.html HTTP/1.1\\r\\n\"\n f\"Host: {target}\\r\\nConnection: Upgrade, HTTP2-Settings\\r\\n\"\n f\"Upgrade: h2c\\r\\nHTTP2-Settings: AAMAAABkAARAAP__AAIAAAAA\\r\\n\\r\\n\".encode()\n)\n```\n\nThe path is a deliberate choice and the PoC's README documents the iteration. `/control/login` was tried first; it returns 404 to a `GET` because the endpoint is `POST`-only, and a 404 on stream 1 confuses the HTTP/2 client. `/login.html` returns `200 OK`, the static asset arrives cleanly on stream 1, and subsequent streams can be requested without poisoning the connection.\n\n`/login.html` was also picked because the auth middleware lets it through unauthenticated. The pre-patch `authhttp.go` defines `isPublicResource`:\n\n```go\nisLogin, err := path.Match(\"/login.*\", p)\n// ...\npaths := []string{\n \"/dns-query\",\n \"/control/login\",\n \"/apple/doh.mobileconfig\",\n \"/apple/dot.mobileconfig\",\n \"/control/install/get_addresses\",\n \"/control/install/check_config\",\n \"/control/install/configure\",\n \"/install.html\",\n}\n\nreturn isAsset || isLogin || slices.Contains(paths, p)\n```\n\nA logged-out browser has to be able to fetch `/login.html` to render the form. The auth middleware exempts it. So the upgrade request, `GET /login.html`, lands in `handlePublicAccess`, which calls `h.ServeHTTP(w, r)` on the inner handler. The inner handler at that layer is `h2c.NewHandler(...)`. `h2c.NewHandler` does the upgrade, hijacks the connection, and starts dispatching streams to `withMiddlewares(mux, limitRequestBody)`. The PoC then opens a new HTTP/2 stream:\n\n```python\nsid = c.get_next_available_stream_id()\nc.send_headers(sid, [\n (\":method\", \"GET\"), (\":path\", path),\n (\":scheme\", \"http\"), (\":authority\", target),\n (\"accept\", \"application/json\"),\n], end_stream=True)\n```\n\n`path` defaults to `/control/filtering/status`. That endpoint is firmly inside the auth-required surface. The README enumerates several others worth pulling on a default install: `/control/clients` returns the DHCP and DNS client list, `/control/querylog` returns the DNS query log (which on home and small-office deployments often contains internal hostnames the operator would not publish on purpose), `/control/dns_info` returns the upstream DNS configuration, `/control/status` returns the listening interfaces. The auth middleware never sees any of these. The connection is on the wrong side of the hijack.\n\nThe README also notes that `curl --http2-prior-knowledge` does not trigger the bug. Prior-knowledge HTTP/2 sends an HTTP/2 preface as the first bytes on the socket, before any HTTP/1.1 layer runs. The outer `http.Server` is HTTP/1.1-only on the plain listener; the preface looks like a malformed HTTP/1.1 request, the auth middleware sees a malformed request and returns 401, the connection closes. Prior-knowledge HTTP/2 does not work because the auth middleware is correctly placed on the HTTP/1.1 layer. Cleartext upgrade works because the auth middleware is correctly placed on the HTTP/1.1 layer and that is the only place it is placed.\n\n## The middleware is request-scoped. The hijacker is connection-scoped.\n\nWrapping a request-scoped middleware around a connection-scoped handler is a category error. The middleware's contract is that for each call into its `ServeHTTP`, it can choose to continue or to short-circuit with a 401. h2c's path 2 makes exactly one call into `ServeHTTP` per connection, and that call returns only when the entire HTTP/2 conversation is over. Every request that arrived between the upgrade and the disconnect came from inside `http2.Server.ServeConn`, a goroutine the outer middleware does not own and was never given a handle to.\n\nThe fixed code makes the middleware a property of the inner handler instead of a property of the outer `http.Server`. Because `http2.Server.ServeConn` dispatches each stream to the inner handler, the middleware now sees every stream. The auth check returns to being a per-request check, which is what its name has always implied.\n\nThe patch carries its own commentary in the post-patch source:\n\n```go\n// Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies.\n//\n// NOTE: The auth middleware must be inside the h2c handler to ensure\n// it applies to upgraded HTTP/2 connections as well. See AG-51779.\nhdlr = h2c.NewHandler(hdlr, &http2.Server{})\n```\n\nThat comment is the bug class stated as a rule. This is the middleware-outside-the-hijacker shape: a request-scoped check wrapped around a handler that can hijack the underlying TCP connection and then serve subsequent traffic from inside its own goroutine. h2c is the obvious instance. WebSocket upgrade handlers are another. `http.Hijacker`-using libraries (CONNECT proxies, server-sent events with custom framing, raw TCP tunnelled over HTTP) are another. Any code path where `ResponseWriter.Hijack` is called is a path where outer middleware silently drops out of scope.\n\nThe \"for proxies\" justification in the original comment is worth sitting with. h2c is enabled by default on every AdGuard Home plain-HTTP listener because at some point a deployment scenario was contemplated in which a reverse proxy would speak h2c upstream to AdGuard Home. The feature ships enabled to all users; the deployment scenario applies to a small subset of those users. The bug is reachable on the default install.\n\n## The CWE is CWE-287. The pattern is in the layering.\n\nCVE-2026-32136 is filed as CWE-287, Improper Authentication, with the description \"an unauthenticated remote attacker can bypass all authentication in AdGuardHome.\" That is accurate at the level of impact. It is not the structural reason.\n\nThe structural reason is that the auth middleware was attached at the wrong layer of a five-line chain. The chain was correctly written for HTTP/1.1. It would also be correctly written for HTTP/2-over-TLS, because in that case the standard library never re-enters userland with a hijacked socket; ALPN-negotiated HTTP/2 is served by `net/http`'s HTTP/2 implementation under `http.Server` directly, and the outer middleware sees every stream. The h2c handler is the specific intermediary that breaks the assumption. It exists to support unencrypted HTTP/2 for reverse-proxy scenarios, and the AdGuard Home web server enables it on every plain-HTTP listener whether the deployment uses it or not.\n\nThe patch is two lines of reorder. The bug is a layer-ordering decision. The class of bug, request-scoped middleware wrapping a connection-hijacking handler, is general and routinely re-discovered. The advisory credits @mandreko, an external reporter. The CHANGELOG note for `v0.107.73` reads, in full: \"Authentication is now applied to requests that have been upgraded from HTTP/2 Cleartext (H2C) requests to public resources.\"\n\nThat sentence describes the fix. The shape of the fix is admission that the previous behavior was the inverse. Authentication was not applied to requests that had been upgraded from h2c on a public resource. The CVE description does not name the layering. The patch comment does. The advisory does not. The bug was a comment that was never written until it was.\n\nPoC: [0xdak/CVE-2026-32136_exploit](https://github.com/0xdak/CVE-2026-32136_exploit)","closing_line":"The auth middleware ran on the request that asked to upgrade. Everything after the upgrade was inside a hijacked socket the middleware no longer saw.","hook_md":"The patch swaps two lines. Before, AdGuard Home wrapped `web.auth.middleware()` around `h2c.NewHandler`. After, it wraps `h2c.NewHandler` around `web.auth.middleware()`. The two configurations look symmetric. They are not. One of them authenticates HTTP requests; the other authenticates the act of opening a connection that will then carry HTTP requests with no further check.","post_id":63,"slug":"adguard-home-auth-outside-h2c","title":"CVE-2026-32136: AdGuard Home Authenticated the Upgrade, Not the Streams","type":"initial","unreadable_sentence":"The auth middleware ran on the request that asked to upgrade. Everything after the upgrade was inside a hijacked socket the middleware no longer saw."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCajgaDAAKCRDeZjl4jgkQ JpHkAP0ZMEO4WTs5X+NDDWNB6ZeMEGdFBQfeOa8h3L58nApQPwD/T4oiuTjfEwxS GbnLzrMZGz60L+8WAZl0CUALpiKlQQY= =Ft5j -----END PGP SIGNATURE-----