FlawOpen

flawopen.com/ssrf/Kotlin

● CWE-918 · High
FlawOpen Security Research

Kotlin SSRF: secure Ktor and OkHttp

Ktor SSRF can reach internal services through user URLs. Filter listed ranges in OkHttp DNS, disable redirects, set timeouts, and enforce separate egress controls; the code filter is not a complete global-reachability test.

💡 Plain English Explainer (ELI5)

A parcel-sorting hub checks the first depot on a label, then blindly follows any forwarding notice the depot sends. A courier carrying the company badge can end up at a staff-only control room even though the original label looked public. A Ktor service that fetches a caller-supplied URL has the same boundary: validating one DNS answer is not enough if OkHttp resolves again or follows a redirect. Validate the actual destination at connection time, restrict redirects, and limit outbound network access.

Core Concepts & Subsystem Terms

Ktor client followRedirects
The Ktor client installs its redirect handling by default, so followRedirects is true unless you turn it off. A public URL answering Location: http://169.254.169.254/ reaches the metadata service after any check on the original URL.
A loopback-only check
isLoopbackAddress rejects only 127.0.0.0/8 and ::1. Private ranges, 169.254.169.254, 0.0.0.0 and CGNAT addresses pass, and InetAddress.getByName returns only one of the host's addresses.
Check-then-fetch and DNS rebinding
The check resolves the hostname once and the HTTP engine resolves it again when it connects. A DNS server with a short TTL can hand the check a public address and the engine 127.0.0.1, so the check proves nothing about the connection.
OkHttp engine with a custom Dns
HttpClient(OkHttp) with engine { preconfigured = ... } lets you pass an OkHttpClient whose Dns returns only public addresses; OkHttp connects only to those, for every request. IP-literal hosts do not go through Dns, so run the same check on the parsed host first, and set Proxy.NO_PROXY. This sample blocks only the classes shown; it is not a complete IANA global-reachability test. Keep the range policy current and enforce egress independently.
HttpTimeout and response size
The HttpTimeout plugin bounds connect and total request time, and reading at most a fixed number of bytes from bodyAsChannel() stops a huge or never-ending response from tying up the service.

Framework mappings

Step-by-Step Attack Flow

Step 1

A user supplies a URL

The route reads FetchRequest with call.receive and passes its caller-controlled URL to the Ktor HttpClient backed by CIO or OkHttp.

Step 2

The service connects to an internal address

A loopback-only precheck misses other private destinations. The client resolves the hostname again, and its default redirect behavior can follow a public URL to an internal address; DNS answers can also change between checks.

Step 3

The internal service answers the trusted caller

The request originates from the application’s network position, where internal APIs, sidecars, or cloud metadata endpoints may be reachable. The preview route can return response text; the webhook route exposes response status and can cause a request-side effect.

Step 4

The attacker reads secrets or pivots

Allow only required schemes and approved hosts, validate and pin resolved public addresses in the client’s DNS/connection path, disable redirects or revalidate every hop, bound response size and time, and restrict egress at the network layer.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// FetchRoutes.kt: both routes let the caller choose the destination
package com.example.fetch

import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import java.net.InetAddress
import java.net.URI
import kotlinx.serialization.Serializable

@Serializable
data class FetchRequest(val url: String)

val client = HttpClient(CIO)

fun Route.fetchRoutes() {
    // any URL is fetched, and the Ktor client follows redirects
    post("/preview") {
        val input = call.receive<FetchRequest>()
        call.respondText(client.get(input.url).bodyAsText().take(5000))
    }

    // checks one address for loopback only; the engine resolves again
    post("/webhooks/test") {
        val input = call.receive<FetchRequest>()
        if (InetAddress.getByName(URI(input.url).host).isLoopbackAddress) {
            call.respond(HttpStatusCode.BadRequest)
            return@post
        }
        val response = client.post(input.url) {
            contentType(ContentType.Application.Json)
            setBody("{\"ping\":true}")
        }
        call.respond(mapOf("status" to response.status.value))
    }
}
HARDENED SECURE PATCH
// OkHttp applies a baseline address filter; independently enforce current network egress policy
package com.example.fetch

import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import io.ktor.utils.io.jvm.javaio.toInputStream
import java.net.InetAddress
import java.net.Proxy
import java.net.UnknownHostException
import kotlinx.serialization.Serializable
import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient

@Serializable
data class FetchRequest(val url: String)

// baseline filter only; this is not a complete IANA global-reachability classifier
fun InetAddress.passesBaselineFilter(): Boolean {
    val b = address
    val cgnat = b.size == 4 && (b[0].toInt() and 0xff) == 100 && (b[1].toInt() and 0xc0) == 64
    val uniqueLocal = b.size == 16 && (b[0].toInt() and 0xfe) == 0xfc
    return !(isAnyLocalAddress || isLoopbackAddress || isLinkLocalAddress || isSiteLocalAddress ||
        isMulticastAddress || cgnat || uniqueLocal)
}

// OkHttp connects only to what this returns
object FilteredDns : Dns {
    override fun lookup(hostname: String): List<InetAddress> {
        val addresses = Dns.SYSTEM.lookup(hostname)
        if (addresses.isEmpty() || !addresses.all { it.passesBaselineFilter() }) {
            throw UnknownHostException("blocked destination $hostname")
        }
        return addresses
    }
}

// no proxy, no redirects, timeouts; IP literals are checked in checkedUrl
val client = HttpClient(OkHttp) {
    followRedirects = false
    install(HttpTimeout) {
        connectTimeoutMillis = 3_000
        requestTimeoutMillis = 10_000
    }
    engine {
        preconfigured = OkHttpClient.Builder()
            .dns(FilteredDns)
            .proxy(Proxy.NO_PROXY)
            .followRedirects(false)
            .followSslRedirects(false)
            .build()
    }
}

fun checkedUrl(raw: String): String {
    val url = raw.toHttpUrlOrNull() ?: throw IllegalArgumentException("only http and https URLs are allowed")
    FilteredDns.lookup(url.host)
    return url.toString()
}

suspend fun HttpResponse.firstBytes(limit: Int): String =
    bodyAsChannel().toInputStream().use { String(it.readNBytes(limit)) }

// at most 5000 bytes are read from any response
fun Route.fetchRoutes() {
    post("/preview") {
        val target = runCatching { checkedUrl(call.receive<FetchRequest>().url) }.getOrElse {
            call.respond(HttpStatusCode.BadRequest)
            return@post
        }
        call.respondText(client.get(target).firstBytes(5000))
    }

    post("/webhooks/test") {
        val target = runCatching { checkedUrl(call.receive<FetchRequest>().url) }.getOrElse {
            call.respond(HttpStatusCode.BadRequest)
            return@post
        }
        val response = client.post(target) {
            contentType(ContentType.Application.Json)
            setBody("{\"ping\":true}")
        }
        call.respond(mapOf("status" to response.status.value))
    }
}

Engineering & System Hardening Checklist

Sources