DNS Spoofing Testing on Android — What It Is, Why It Matters, and 4 Hands-On Lab Approaches
One of the things I never thought about until working on a banking app: when your app calls api.mybank.com, who decides which IP address that resolves to? The answer is the DNS resolver, and the resolver is controlled by the network the device is connected to. On your home Wi-Fi, your ISP’s DNS gives you the real IP. On a compromised Wi-Fi — a coffee-shop hotspot, a hotel network, a malicious access point — the attacker controls DNS and can point api.mybank.com to their own server. Your app connects, sends credentials, and the attacker has them.
That’s DNS spoofing. It’s one of the simplest network attacks and one of the most effective against apps that don’t defend properly. The defense is certificate pinning (covered in the Security foundation post), but how do you test that your pinning actually works? How do you verify your app rejects the spoofed connection instead of blindly trusting it?
This post is the hands-on lab. First: what DNS spoofing is and why it matters for your app, explained from scratch. Then: four practical approaches to test your app’s resilience, each with step-by-step commands you can run on your machine right now. You don’t need to be a security researcher — you need an Android emulator, a terminal, and 30 minutes.
What Is DNS Spoofing? (The 2-Minute Explanation)
When your app calls api.mybank.com, the operating system doesn’t know which server to connect to. It asks a DNS (Domain Name System) resolver: “what IP address is api.mybank.com?” The resolver answers (e.g., 203.0.113.42), and the app connects to that IP.
Normal flow:
App → “resolve api.mybank.com” → DNS resolver → “203.0.113.42” (real server)
App → connects to 203.0.113.42 → real bank server → ✅ safe
DNS spoofing:
App → “resolve api.mybank.com” → ATTACKER’s DNS → “198.51.100.99” (attacker server)
App → connects to 198.51.100.99 → attacker’s fake server → ❌ credentials stolen
The attacker doesn’t need to hack your server or your app. They just need to control the DNS resolution — which they can do by running a fake Wi-Fi hotspot, compromising a router, or poisoning a DNS cache.
Why TLS Alone Doesn’t Fully Protect You
You might think: “but my app uses HTTPS, so TLS will catch this.” It depends. Standard TLS verifies the server’s certificate against the device’s trust store (~150 root CAs). If the attacker can get a valid certificate for your domain from any of those CAs (through compromise, social engineering, or a rogue CA), they present a valid certificate and TLS accepts it. Or if the user has installed a custom root CA on their device (for work VPN, debugging, or because a phishing app instructed them to), the attacker’s self-signed cert is trusted.
Certificate pinning is what closes this gap: you pin your app to specific certificates or public keys, so even with a valid-looking cert from the wrong CA, your app rejects the connection. But pinning only works if you’ve implemented it correctly. This post teaches you how to verify that.
What We’re Testing
The goal of each lab: redirect your app’s DNS resolution to a server you control, and verify that your app either (a) rejects the connection entirely (if cert pinning is properly configured) or (b) connects to the fake server (meaning your app is vulnerable). The labs simulate what an attacker would do; you’re just doing it to your own app in a controlled environment.
Approach 1: Hosts File on Android Emulator (Simplest)
The easiest way to test DNS spoofing: edit the emulator’s /etc/hosts file to point your API domain at a different IP. No tools needed beyond the Android emulator and adb.
Prerequisites
- Android Emulator (any API level) — must be a non-Play-Store system image (the “Google APIs” variant, not “Google Play”), because Play Store images don’t allow root access to the filesystem
adb(comes with Android Studio)- A simple HTTP server to act as the “attacker’s server” (Python one-liner works)
Step-by-Step
Step 1: Start a fake “attacker” server on your machine.
# Terminal 1: Start a simple HTTPS server on port 8443
# First, generate a self-signed certificate for the fake server
openssl req -x509 -newkey rsa:2048 -keyout fake_key.pem -out fake_cert.pem \
-days 365 -nodes -subj “/CN=api.mybank.com”
# Run a simple Python HTTPS server
python3 -c “
import ssl, http.server
handler = http.server.SimpleHTTPRequestHandler
httpd = http.server.HTTPServer((‘0.0.0.0’, 8443), handler)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(‘fake_cert.pem’, ‘fake_key.pem’)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
print(‘Fake server running on https://0.0.0.0:8443’)
httpd.serve_forever()
”
This runs a server with a self-signed certificate claiming to be api.mybank.com. A properly-pinned app should reject this certificate.
Step 2: Find your host machine’s IP address.
# On Mac:
ifconfig en0 | grep “inet ”
# Example output: inet 192.168.1.100 ...
# On Linux:
ip addr show | grep “inet ”
# Find your local IP (192.168.x.x or 10.x.x.x)
Step 3: Edit the emulator’s hosts file to redirect your domain.
# Make the emulator’s filesystem writable
adb root
adb remount
# Pull the current hosts file
adb pull /etc/hosts ./hosts_backup
# Add your spoofed entry
echo “192.168.1.100 api.mybank.com” >> ./hosts_backup
# Replace 192.168.1.100 with YOUR host machine’s IP
# Push it back
adb push ./hosts_backup /etc/hosts
# Verify it took effect
adb shell “cat /etc/hosts”
# You should see: 192.168.1.100 api.mybank.com
# Verify DNS resolution changed
adb shell “ping -c 1 api.mybank.com”
# Should now resolve to 192.168.1.100 instead of the real IP
Step 4: Open your app on the emulator and trigger an API call.
Watch what happens:
- If your app has NO certificate pinning: the app connects to your fake server. In your fake server’s terminal, you’ll see the request arrive. Your app received a response from an attacker-controlled server and treated it as legitimate. This is the vulnerability.
- If your app HAS certificate pinning (OkHttp CertificatePinner or Network Security Config): the app rejects the connection with an
SSLPeerUnverifiedExceptionorCertPathValidatorException. The fake server’s self-signed cert doesn’t match the pinned certificate. This is the defense working.
Step 5: Clean up.
# Restore the original hosts file
adb push ./hosts_backup_original /etc/hosts
# Or just restart the emulator — hosts file resets to default
Limitations of This Approach
The hosts file redirects the domain to your IP, but your fake server listens on port 8443 while your app probably connects to port 443. Two options: (a) change your fake server to port 443 (requires root on your host machine), or (b) use iptables on the emulator to redirect port 443 traffic to 8443:
adb shell “iptables -t nat -A OUTPUT -p tcp --dport 443 -d 192.168.1.100 -j REDIRECT --to-port 8443”
This approach is the simplest but only works on emulators with root access. Real devices need a different method.
Approach 2: Local DNS Server with dnsmasq (Works on Real Devices Too)
For testing on real devices (or emulators where you can’t edit /etc/hosts), run a local DNS server on your computer and configure the device to use it.
Prerequisites
dnsmasqinstalled on your computer (brew install dnsmasqon Mac,apt install dnsmasqon Linux)- Device and computer on the same Wi-Fi network
Step-by-Step
Step 1: Configure dnsmasq to spoof your domain.
# Create or edit dnsmasq config
# Mac: /usr/local/etc/dnsmasq.conf or /opt/homebrew/etc/dnsmasq.conf
# Linux: /etc/dnsmasq.conf
# Add this line to spoof your domain:
address=/api.mybank.com/192.168.1.100
# This tells dnsmasq: “resolve api.mybank.com to 192.168.1.100”
# All other domains resolve normally (dnsmasq forwards to upstream DNS)
# Start dnsmasq
sudo dnsmasq --no-daemon --log-queries
# --no-daemon keeps it in the foreground so you see queries
# --log-queries shows every DNS query the device makes
Step 2: Point your device’s DNS to your computer.
On a real device: Settings → Wi-Fi → long-press your network → Modify network → Advanced → IP settings: Static → DNS 1: your computer’s IP (192.168.1.100).
On an emulator: launch with a custom DNS:
emulator -avd Pixel_6_API_34 -dns-server 192.168.1.100
Step 3: Start your fake HTTPS server (same as Approach 1, Step 1).
Step 4: Open your app and trigger an API call.
Watch the dnsmasq log — you’ll see the query for api.mybank.com and the spoofed response. Then check whether your app accepts or rejects the fake server’s certificate.
Step 5: Clean up. On the device, revert DNS settings to automatic. Stop dnsmasq.
Why This Approach Is Better
Works on real devices (no root needed — you’re just changing the DNS server in Wi-Fi settings). More realistic simulation (the device’s full network stack is involved). You can see every DNS query in dnsmasq’s log, which is educational — you’ll discover your app makes queries you didn’t expect.
Approach 3: mitmproxy / Charles Proxy (Full MITM Simulation)
For the most realistic test: run a man-in-the-middle proxy that intercepts HTTPS traffic. This simulates a more sophisticated attacker who not only spoofs DNS but also presents a fake certificate and proxies traffic.
Prerequisites
mitmproxyinstalled (brew install mitmproxyorpip install mitmproxy) — or Charles Proxy if you prefer a GUI- Device configured to use the proxy
Step-by-Step
Step 1: Start mitmproxy.
# Start mitmproxy listening on port 8080
mitmproxy --listen-port 8080
# Or for a web interface:
mitmweb --listen-port 8080
Step 2: Configure the device to use the proxy.
On a real device: Settings → Wi-Fi → your network → Proxy: Manual → Host: your computer’s IP, Port: 8080.
On an emulator:
emulator -avd Pixel_6_API_34 -http-proxy 192.168.1.100:8080
Step 3: Install mitmproxy’s CA certificate on the device.
This is the critical step. mitmproxy generates its own root CA; you install it on the device so the device trusts mitmproxy’s certificates. This simulates an attacker who has convinced the user to install a rogue CA (social engineering) or compromised the device’s trust store.
# On the device’s browser, navigate to:
# http://mitm.it
# Download and install the Android certificate
# Settings → Security → Encryption & Credentials → Install a certificate → CA certificate
# For emulators with root, you can install as a system CA:
adb root
adb remount
# Copy mitmproxy’s CA cert to the system trust store
hashed_name=$(openssl x509 -inform PEM -subject_hash_old -in ~/.mitmproxy/mitmproxy-ca-cert.pem | head -1)
adb push ~/.mitmproxy/mitmproxy-ca-cert.pem /system/etc/security/cacerts/${hashed_name}.0
adb shell chmod 644 /system/etc/security/cacerts/${hashed_name}.0
adb reboot
Step 4: Open your app and trigger API calls.
Watch the mitmproxy dashboard. Every HTTPS request your app makes appears in cleartext — mitmproxy decrypted it using its own certificate, which the device now trusts.
Now the test:
- If your app has NO certificate pinning: all requests appear in mitmproxy in cleartext. The MITM attack succeeded. You can read request bodies, auth tokens, everything.
- If your app HAS certificate pinning: the requests fail with an SSL error. mitmproxy shows a connection attempt but the app rejected the handshake because the certificate doesn’t match the pinned key. This is the defense working.
What This Approach Tests That Others Don’t
mitmproxy simulates a full MITM, not just DNS spoofing. It tests: does your app trust arbitrary CAs? Does certificate pinning actually reject non-pinned certificates? Does your app leak data in HTTP (non-HTTPS) requests? What headers and tokens does your app send? This is the most comprehensive test of your app’s network security posture.
Approach 4: Programmatic Testing in Your App Code
For automated testing (CI, regression suites), you don’t want to set up external infrastructure. Instead, simulate DNS resolution attacks directly in your OkHttp client using a custom Dns implementation.
// A custom Dns implementation that redirects specific domains
class SpoofedDns(
private val spoofMap: Map<String, String> // domain → spoofed IP
) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
val spoofedIp = spoofMap[hostname]
return if (spoofedIp != null) {
// Return the spoofed IP instead of the real one
listOf(InetAddress.getByName(spoofedIp))
} else {
// Normal resolution for non-spoofed domains
Dns.SYSTEM.lookup(hostname)
}
}
}
// Usage in a test
@Test
fun `certificate pinning rejects spoofed DNS`() = runTest {
// Create an OkHttp client with spoofed DNS + real cert pinning
val spoofedDns = SpoofedDns(mapOf(
“api.mybank.com” to “127.0.0.1” // Redirect to localhost
))
val client = OkHttpClient.Builder()
.dns(spoofedDns)
.certificatePinner(
// Same pinning config your production app uses
CertificatePinner.Builder()
.add(“api.mybank.com”, “sha256/YOUR_REAL_PIN_HERE”)
.build()
)
.build()
// Start a local server with a self-signed cert (not matching the pin)
val server = MockWebServer()
server.useHttps(createSelfSignedSslContext().socketFactory, false)
server.enqueue(MockResponse().setBody(“{\“stolen\”: true}”))
server.start(443) // Listen on 443 so the redirect hits it
// Attempt the request — should FAIL due to pinning
val request = Request.Builder()
.url(“https://api.mybank.com/api/account”)
.build()
try {
client.newCall(request).execute()
fail(“Should have thrown SSLPeerUnverifiedException”)
} catch (e: SSLPeerUnverifiedException) {
// ✅ Certificate pinning caught the spoofed DNS!
// The connection was rejected because the cert didn’t match the pin.
} catch (e: SSLHandshakeException) {
// ✅ Also acceptable — TLS handshake failed
} finally {
server.shutdown()
}
}
This approach is powerful for several reasons:
1. Runs in CI. No emulator, no external DNS server, no manual setup. It’s a unit test.
2. Tests exactly the defense you care about. The custom Dns implementation proves that even if DNS is compromised, OkHttp’s CertificatePinner rejects the connection.
3. Regression-proof. If someone accidentally removes or misconfigures the pinning, this test fails. Ship it in your CI pipeline and you’ll catch the regression before release.
A More Complete Test Suite
class DnsSpoofingDefenseTest {
@Test
fun `pinned domain rejects spoofed DNS with wrong certificate`() {
// As above — should throw SSLPeerUnverifiedException
}
@Test
fun `pinned domain accepts real certificate`() {
// Same client with pinning, but pointing to the REAL server
// Should succeed — verifies pinning doesn’t break legitimate connections
}
@Test
fun `unpinned domain is vulnerable to spoofed DNS`() {
// A domain NOT in the CertificatePinner accepts any valid cert
// This is expected behavior — document it as a known limitation
}
@Test
fun `app handles pinning failure gracefully in UI`() {
// When pinning fails, the app should show a meaningful error
// not crash or show a generic “network error”
}
}
What Your App Should Do When It Detects a Spoofed Connection
Testing tells you whether your defense works. The next question: what should the app do when it detects the attack?
// In your Retrofit/OkHttp error handling
class SecureApiClient @Inject constructor(
private val client: OkHttpClient
) {
suspend fun fetchAccount(): Result<Account> = try {
val response = client.newCall(request).await()
Result.success(parseAccount(response))
} catch (e: SSLPeerUnverifiedException) {
// Certificate pinning failed — possible MITM attack
logSecurityEvent(“cert_pin_failure”, e.message)
Result.failure(SecurityException(
“Connection security could not be verified. ” +
“Please switch to a trusted network and try again.”
))
} catch (e: SSLHandshakeException) {
// TLS handshake failed for other reasons
logSecurityEvent(“ssl_handshake_failure”, e.message)
Result.failure(SecurityException(“Secure connection failed.”))
}
}
// In the UI
@Composable
fun AccountScreen(viewModel: AccountViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
when (state) {
is UiState.SecurityError -> {
// Show a clear, non-technical message
SecurityWarningDialog(
message = state.message,
onDismiss = { viewModel.retry() }
)
// Do NOT show a “skip” or “continue anyway” button.
// The whole point of pinning is that the user can’t override it.
}
// ... other states
}
}
Three rules for handling pinning failures:
1. Never let the user bypass the check. No “Continue Anyway” button. The attacker controls what the user sees on the fake server; a “continue” option defeats the defense entirely.
2. Show a human-readable message. “Connection security could not be verified. Switch to a trusted network.” Not “SSLPeerUnverifiedException.”
3. Log the event server-side. A spike in cert-pinning failures from a specific region or ISP is intelligence — it might indicate a targeted attack or a compromised CDN node.
The Defense: Certificate Pinning (Quick Recap)
The Security foundation post covered this in depth. Here’s the implementation recap so this post is self-contained for someone following the labs:
Option A: OkHttp CertificatePinner
val client = OkHttpClient.Builder()
.certificatePinner(
CertificatePinner.Builder()
.add(“api.mybank.com”,
“sha256/AAAA...your primary pin...AAAA=”,
“sha256/BBBB...your backup pin...BBBB=”)
.build()
)
.build()
// Get pin hashes from your server’s certificate:
// openssl s_client -connect api.mybank.com:443 | openssl x509 -pubkey -noout |
// openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64
Option B: Network Security Config (Declarative, XML)
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<domain-config>
<domain includeSubdomains=“true”>api.mybank.com</domain>
<pin-set expiration=“2026-12-31”>
<pin digest=“SHA-256”>AAAA...primary...=</pin>
<pin digest=“SHA-256”>BBBB...backup...=</pin>
</pin-set>
</domain-config>
<!-- Debug-only: allow mitmproxy/Charles for development -->
<debug-overrides>
<trust-anchors>
<certificates src=“user” />
</trust-anchors>
</debug-overrides>
</network-security-config>
<!-- In AndroidManifest.xml -->
<application android:networkSecurityConfig=“@xml/network_security_config”>
The debug-overrides section is important for development: it lets you use mitmproxy/Charles during debugging (user-installed CAs are trusted in debug builds) while release builds enforce strict pinning. This is how you develop with proxy tools without weakening your production security.
Which Approach to Use When
┌────────────────────────────────────────────────────────────────────┐
│ Scenario │ Recommended Approach │
├─────────────────────────────────┼──────────────────────────────────┤
│ Quick manual test on emulator │ Approach 1: hosts file │
│ Test on a real device │ Approach 2: dnsmasq │
│ Full MITM simulation, traffic │ Approach 3: mitmproxy │
│ inspection, security audit │ │
│ CI automated regression test │ Approach 4: OkHttp custom Dns │
│ Pen-test / security review │ Approach 3 + Approach 4 │
│ Developer debugging API traffic │ Approach 3 (debug build only) │
└────────────────────────────────────────────────────────────────────┘
For a banking app team, I’d recommend:
- Approach 4 in CI (automated, catches regressions)
- Approach 3 during manual security review (comprehensive, visual)
- Approach 1 or 2 for quick verification after changes to pinning config
Beyond DNS Spoofing: What Else to Test
DNS spoofing is one network attack vector. While you’re testing, consider these related scenarios:
Certificate expiry. What happens when your pinned cert expires and you haven’t updated the app? The app should fail gracefully, not crash. Test by pinning an expired cert intentionally.
Downgrade attacks. Does your app ever fall back to HTTP if HTTPS fails? It shouldn’t. Network Security Config’s cleartextTrafficPermitted=“false” enforces this.
Certificate transparency violations. Some apps check CT logs to detect misissued certificates. If you use this, test that violations are caught.
WiFi captive portals. Hotel/airport Wi-Fi with a login page. Your app’s requests get redirected to the portal’s login page, which returns HTML instead of your API’s JSON. Does your app handle this gracefully or crash parsing the unexpected response?
VPN and proxy environments. Corporate VPNs that MITM all traffic for inspection. Your cert pinning will reject these connections — is the error message helpful? Can the user understand what’s happening?
Common Mistakes When Testing
Testing only on debug builds. Debug builds with debug-overrides trust user-installed CAs — your pinning test passes on debug but would fail on release (or vice versa). Always test pinning behavior on a release build (or at minimum, a debug build without the debug-overrides).
Forgetting that Android N+ ignores user CAs by default. On Android 7.0+, user-installed certificates are not trusted by default for apps targeting API 24+. This means mitmproxy won’t work on release builds even without pinning unless you install the CA as a system cert (requires root). This is a good default that protects users; it just complicates your testing setup.
Pinning to only one certificate with no backup. When you test, also test the rotation scenario: what happens when the primary cert expires and only the backup pin is valid? Does the app still connect? If you only pinned one cert and it rotates, every user is locked out.
Not testing the negative case. Verifying that pinning blocks the attack is half the test. The other half: verifying that legitimate connections with the real certificate still work. A misconfigured pin that blocks everything is worse than no pin at all.
Closing
DNS spoofing is one of the simplest network attacks and one of the easiest to test against. The four approaches in this post cover the spectrum from quick manual checks (hosts file) to automated CI regression tests (OkHttp custom Dns), with full MITM simulation (mitmproxy) for comprehensive security audits. Each approach takes under 30 minutes to set up, runs on tools you already have, and gives you a concrete pass/fail answer: is your app vulnerable to DNS-level redirection, or does certificate pinning catch it?
The recurring theme from this Security cluster holds: client-side defense (pinning) raises the bar, but you need to verify it actually works. Untested pinning is hope, not security. These labs turn hope into evidence.
For a banking or enterprise app: run Approach 4 in every CI build (catches regressions automatically), run Approach 3 during quarterly security reviews (catches configuration drift), and run Approach 1 or 2 whenever you change your pinning config (catches mistakes immediately). Three investments, comprehensive coverage.
Happy coding!
Comments (0)
Sign in to leave a comment.
No comments yet. Be the first to share your thoughts.