- 2024/03/11

DNS over HTTPS (DoH) is bad news for DNS-based request blocking. If the HTTP client can set a custom DNS resolver that points to private DoH servers, whatever regular DNS-based blocking mechanism you use will be bypassed.

Let’s go deeper.

DoH is a protocol for performing remote Domain Name System (DNS) resolution via the HTTPS protocol. A goal of the method is to increase user privacy and security by preventing eavesdropping and manipulation of DNS data by man-in-the-middle attacks by using the HTTPS protocol to encrypt the data between the DoH client and the DoH-based DNS resolver.

On the other hand, the way DNS-based request blocking works is simple, I wrote my own DNS proxy server that will block the DNS requests if they match with a certain criteria, which could be the exact domain name, prefix, suffix, regex, server IPs from A-type response, etc. See the figure for the normal flow below.

It is as simple as "Hey, what is the IPv4 address of example.com?", the proxy then checks its rules whether example.com is matched with one of the block rules, and if yes, "OK, it is 0.0.0.0" (nulled IP or DNS error response), if not matched with the blocked rules, they will forward the request to the whatever upstream DNS resolver I set, then "OK, it is x.x.x.x", where x.x.x.x is the actual IP address for example.com.

Everything is fine with the normal flow, the client uses the default DNS lookup mechanism, and the DNS proxy does the job, blocking, forwarding, and logging whatever they want.

Well, until I found out these client’s logs which is an iOS device used by non-techies.

That domain from the log data is the Cloudflare public DNS resolver that can be accessed via DNS-over-TLS (DoT).

This then makes me wonder "If the mobile app uses a custom DNS resolver baked in their HTTP client, they could bypass the DNS proxy and the request will not be logged". And yes, those things are possible.

How?

OK, let’s prove it using Kotlin script to simulate Android mobile app HTTP call via OkHttp.

In the DNS proxy configuration, I blocked the whole facebook.com domain via a .facebook.com suffix, so any normal HTTP calls to a facebook domain will be blocked.

From the normal HTTP client perspective, where most of the applications will communicate via HTTP to connect to their server, we have the following flow.

// default-resolver.main.kts
@file:DependsOn("com.squareup.okhttp3:okhttp:4.12.0")

import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException

val client = OkHttpClient().newBuilder().build()

val request = Request.Builder().url("https://www.facebook.com/.well-known/openid-configuration").build()

client.newCall(request).execute().use { response ->
    if (!response.isSuccessful) throw IOException("Unexpected code $response")
    println(response.body!!.string())
}

The DNS proxy will block it and from the client’s perspective, it will be timed out.

$ kotlin default-resolver.main.kts 
java.net.SocketTimeoutException: Connect timed out
  at java.base/sun.nio.ch.NioSocketImpl.timedFinishConnect(NioSocketImpl.java:546)
  at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:592)
  at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
  at java.base/java.net.Socket.connect(Socket.java:751)
  at okhttp3.internal.platform.Platform.connectSocket(Platform.kt:128)
  at okhttp3.internal.connection.RealConnection.connectSocket(RealConnection.kt:295)
  at okhttp3.internal.connection.RealConnection.connect(RealConnection.kt:207)
  at okhttp3.internal.connection.ExchangeFinder.findConnection(ExchangeFinder.kt:226)
  at okhttp3.internal.connection.ExchangeFinder.findHealthyConnection(ExchangeFinder.kt:106)
  at okhttp3.internal.connection.ExchangeFinder.find(ExchangeFinder.kt:74)
  at okhttp3.internal.connection.RealCall.initExchange$okhttp(RealCall.kt:255)
  at okhttp3.internal.connection.ConnectInterceptor.intercept(ConnectInterceptor.kt:32)
  at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
  at okhttp3.internal.cache.CacheInterceptor.intercept(CacheInterceptor.kt:95)
  at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
  at okhttp3.internal.http.BridgeInterceptor.intercept(BridgeInterceptor.kt:83)
  at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
  at okhttp3.internal.http.RetryAndFollowUpInterceptor.intercept(RetryAndFollowUpInterceptor.kt:76)
  at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
  at okhttp3.internal.connection.RealCall.getResponseWithInterceptorChain$okhttp(RealCall.kt:201)
  at okhttp3.internal.connection.RealCall.execute(RealCall.kt:154)
  at Default_resolver_main.<init>(default-resolver.main.kts:11)

On the DNS proxy control panel, it is logged and shows that DNS request to www.facebook.com is blocked.

Now let’s try to set the custom DNS resolver to the previous HTTP client code. The flow is changed from the previous normal HTTP call to the following.

If in the normal flow, we use a system DNS resolver, we now set explicitly the DoH resolver.

// custom-resolver.main.kts
@file:DependsOn("com.squareup.okhttp3:okhttp:4.12.0")
@file:DependsOn("com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0")

import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.dnsoverhttps.DnsOverHttps
import java.io.IOException

val baseClient = OkHttpClient().newBuilder().build()

val dns = DnsOverHttps.Builder().client(baseClient)
        .url("https://9.9.9.9/dns-query".toHttpUrl())
        .build()

val client = baseClient.newBuilder().dns(dns).build()

val request = Request.Builder().url("https://www.facebook.com/.well-known/openid-configuration").build()

client.newCall(request).execute().use { response ->
    if (!response.isSuccessful) throw IOException("Unexpected code $response")
    println(response.body!!.string())
}

And…. as expected, my proxy server was bypassed and the request was not logged (because the DoH resolver is an IP address). The HTTP client can freely contact the destined domain.

$ kotlin custom-resolver.main.kts 
{
    "issuer": "https://www.facebook.com",
    "authorization_endpoint": "https://facebook.com/dialog/oauth/",
    "jwks_uri": "https://www.facebook.com/.well-known/oauth/openid/jwks/",
    "response_types_supported": [
        "id_token",
        "token id_token"
    ],
    "subject_types_supported": [
        "pairwise"
    ],
    "id_token_signing_alg_values_supported": [
        "RS256"
    ],
    "claims_supported": [
        "iss",
        "aud",
        "sub",
        "iat",
        "exp",
        "jti",
        "nonce",
        "at_hash",
        "name",
        "given_name",
        "middle_name",
        "family_name",
        "email",
        "picture",
        "user_friends",
        "user_birthday",
        "user_age_range",
        "user_link",
        "user_hometown",
        "user_location",
        "user_gender"
    ]
}

So…

If the DNS-based request blocking intends to block ads and trackers, the ad/tracker company has the option to provide a custom DoH server to be used by their client/SDK.

And while the DoH itself is intended for privacy and to improve the security of communication, the malware also took advantage of it. 1 2

DoH-based request is relatively a blind spot from the monitoring perspective. It is not impossible to detect and or block but it is getting harder and more expensive.