AdGuardHome: Unauthenticated API Access via HTTP/2 Cleartext (h2c) Upgrade

Mar 10, 2026 min read

AdGuardHome is a self-hosted DNS-level ad blocker that a lot of people, myself included, run on their home networks. It sits in front of all your DNS traffic and blocks ads, trackers, and malware domains before they even get a chance to load. It is common on home routers, Raspberry Pis, and small VMs, and a lot of people expose the admin interface directly on their LAN without any extra authentication layer in front of it.

I was doing a security review of the codebase when I noticed something odd in how the HTTP server is initialized.


Overview

GitHub Advisory: https://github.com/AdguardTeam/AdGuardHome/security/advisories/GHSA-5fg6-wrq4-w5gh

CVE: [CVE-PENDING / TBD]

CVSS 3.1 Score: 9.8 CRITICAL (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

CWE: CWE-287 (Improper Authentication)

Affected software: AdGuardHome (tested on master, commit 01dd10e4)

Fixed in: v0.107.73


The Root Cause

AdGuardHome’s HTTP server is built in internal/home/web.go. The relevant section looks like this (simplified):

hdlr := h2c.NewHandler(
    withMiddlewares(web.conf.mux, limitRequestBody),  // no auth
    &http2.Server{},
)
web.httpServer = &http.Server{
    Handler: web.auth.middleware().Wrap(hdlr),  // auth lives here
}

At first glance this looks fine. The auth.middleware().Wrap(hdlr) call wraps the entire thing in authentication, so every request has to pass the auth check, right?

Not quite. The problem is in what h2c.NewHandler actually does.

h2c (HTTP/2 Cleartext) is the unencrypted version of HTTP/2, defined in RFC 7540 Section 3.2. The h2c handler works like this: when it receives an HTTP/1.1 request with an Upgrade: h2c header, it hijacks the underlying TCP connection and hands it off to an HTTP/2 server. That HTTP/2 server then handles all future requests on that connection.

Here is the critical detail. When h2c.NewHandler is called, it stores the inner handler, the one without auth, inside the h2c handler struct. When the TCP connection is hijacked and handed to http2.ServeConn, it passes s.Handler as the handler for all future HTTP/2 requests. That is the inner handler, with no auth attached.

The auth middleware only sees the initial HTTP/1.1 upgrade request. Once the connection is upgraded to HTTP/2, auth is never consulted again. Every subsequent request on that connection is served directly by the inner mux.

Looking at the h2c library source (golang.org/x/net/http2/h2c/h2c.go):

// Handle Upgrade to h2c (RFC 7540 Section 3.2)
if isH2CUpgrade(r.Header) {
    conn, settings, err := h2cUpgrade(w, r)
    ...
    s.s.ServeConn(conn, &http2.ServeConnOpts{
        Handler: s.Handler,  // <-- inner mux, no auth
        ...
    })
    return
}

So the auth middleware wraps the h2c handler at the outer layer, but the h2c handler passes its inner handler directly to ServeConn. The auth wrapper is never in the call path for any HTTP/2 request.

The only remaining question is: can an unauthenticated attacker actually trigger the h2c upgrade? Yes. The upgrade request itself targets a public path such as /control/login, which is whitelisted in isPublicResource() in internal/home/authhttp.go. The auth middleware allows it through, the h2c handler hijacks the connection, and from that point on the inner mux handles everything without authentication.


Exploitation

The attack is straightforward:

  1. Open a TCP connection to AdGuardHome (default port 3000).
  2. Send an HTTP/1.1 GET request to /control/login with the h2c upgrade headers.
  3. The server responds with 101 Switching Protocols.
  4. Complete the HTTP/2 handshake (client preface + SETTINGS exchange).
  5. Send any HTTP/2 request to any API endpoint.
  6. The server responds with full admin access, no credentials required.

Here is what the upgrade request looks like on the wire:

GET /control/login HTTP/1.1
Host: 192.168.1.1:3000
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: <base64url-encoded SETTINGS frame>

The server replies:

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: h2c

After that, the connection is HTTP/2 and every request is treated as authenticated.


Proof of Concept

I wrote a Python PoC that automates this. It uses the h2 library for HTTP/2 request handling and raw socket code for the h2c upgrade and handshake drain phase (more on that in a minute).

Running it against a vulnerable instance:

python3 poc_h2c_auth_bypass.py 192.168.1.15 80 --hijack-dns 8.8.8.8
====================================================================
AdGuardHome -- h2c Authentication Bypass PoC
CWE-287: Full API access without credentials
====================================================================
Target  : http://192.168.1.15:80
Upgrade : /control/login  (whitelisted public path)

[*] Connecting and performing h2c upgrade ...
[+] Bypass established -- authentication is not enforced

[*] GET /control/status
[+] Version      : v0.107.72
[+] DNS addresses: ['127.0.0.1', '::1', '192.168.1.15', 'fd64:b28c:45d2:4b5e:d35c:7660:e1b:92', 'fe80::ba65:3afa:617f:f077%eth0']
[+] HTTP port    : 80
[+] Protection   : ON

[*] GET /control/querylog  (DNS query history)
[+] 10 recent entries:
    2026-03-09T20:42:15  docker.home.andreko.net                   192.168.1.232
    2026-03-09T20:42:00  docker.home.andreko.net                   192.168.1.232
    2026-03-09T20:41:45  docker.home.andreko.net                   192.168.1.232
    2026-03-09T20:41:30  docker.home.andreko.net                   192.168.1.232
    2026-03-09T20:41:12  docker.home.andreko.net                   192.168.1.232

[*] GET /control/dhcp/status  (network device inventory)
[+] Dynamic leases : 0
[+] Static leases  : 0

[*] POST /control/dns_config  (DNS -> 8.8.8.8)
[+] Upstream DNS changed to 8.8.8.8
[+] All DNS queries now route through attacker-controlled server

One Interesting Implementation Detail

The h2 library is strict about RFC 7541 compliance for HPACK header compression. The Go h2c server sends a dynamic table size update in stream 1’s response headers that is not positioned at the very start of the header block, which is technically an RFC violation. The h2 library rejects this with a ProtocolError.

The fix is a hybrid drain phase: the initial setup frames (server SETTINGS and the stream 1 response) are consumed with raw socket parsing, and the h2 library’s internal HPACK decoder is updated manually so that the dynamic table stays in sync with the server’s encoder state. After the handshake, the h2 library takes over normally for all subsequent requests.

It took a few iterations to get right, but the important part is it works.


Impact

From an unauthenticated position on the same network (or remotely if port 3000 is internet-facing), an attacker can:

Read:

  • System version and DNS configuration
  • Full DNS query log, which is essentially the browsing history of every device on the network
  • All DHCP leases, giving a complete inventory of devices including hostnames, MAC addresses, and IP addresses

Write:

  • Change upstream DNS servers to attacker-controlled infrastructure, redirecting all DNS queries network-wide. Combined with a fake DNS server, this enables phishing against any domain for every device on the network.
  • Disable DNS protection entirely, suspending all ad blocking and malware filtering.
  • Change the admin account password, locking the legitimate owner out.
  • Add custom DNS rewrites or filtering rules.

Remediation

The fix is straightforward: move the authentication middleware inside the h2c handler so it applies to the inner mux before h2c stores it.

Vulnerable:

hdlr := h2c.NewHandler(
    withMiddlewares(web.conf.mux, limitRequestBody),  // no auth
    &http2.Server{},
)
web.httpServer = &http.Server{
    Handler: web.auth.middleware().Wrap(hdlr),
}

Fixed:

authedMux := web.auth.middleware().Wrap(
    withMiddlewares(web.conf.mux, limitRequestBody),
)
hdlr := h2c.NewHandler(authedMux, &http2.Server{})
web.httpServer = &http.Server{
    Handler: hdlr,
}

Alternatively, if h2c is not a required feature, removing h2c.NewHandler entirely eliminates the attack surface. HTTP/2 over TLS (standard h2) is not affected because TLS connections do not go through the h2c upgrade path.


Timeline

DateEvent
2026-03-08Vulnerability discovered
2026-03-09Reported to AdGuard security team
2026-03-10AdGuard acknowledged receipt
2026-03-10Fix developed
2026-03-10Fixed version released
2026-03-10Public disclosure

Closing Thoughts

This is a good example of a vulnerability that is easy to miss in code review. The authentication wrapper is right there, visibly wrapping the handler. The issue is not missing auth, it is auth applied at the wrong layer. The h2c upgrade mechanism silently routes around the wrapper for all HTTP/2 traffic, and nothing in the code makes that obvious unless you know how h2c.NewHandler stores its inner handler.

It is also a reminder that h2c support is a non-trivial attack surface. Most applications serving HTTP/2 do so over TLS, where the h2c library is never in the picture. Adding h2c support for plaintext HTTP/2 introduces the upgrade path, and anything that hooks into the middleware chain needs to account for the inner handler being captured at construction time.

If you are running AdGuardHome, upgrade to v0.107.73 which is now available. If you want to check whether your instance is vulnerable in the meantime, you can test whether an h2c upgrade request to /control/login returns a 101.