Skip to content

TLS Handshake Fails When Testing Internal HTTPS Proxy with WKWebView or curl #539

Closed
@daonhat

Description

@daonhat

I'm building an internal HTTPS proxy within an iOS application, with the goal of intercepting requests made by WKWebView.
The proxy is running at 192.168.1.8:9443.

For the purpose of testing the proxy’s TLS handshake behavior, I’ve hardcoded the domain certificate. I’ve also installed and trusted the root certificate on the iOS device.

However, when using a client such as WKWebView or curl, I’m encountering a handshakeFailed error.

TLS error:

TLS error caught: handshakeFailed(NIOSSL.BoringSSLError.sslError([Error: 268435703 error:100000f7:SSL routines:OPENSSL_internal:WRONG_VERSION_NUMBER]))

The curl command I'm using:

curl -x http://192.168.1.8:9443 https://id.obc.jp -k -v

Below is the code snippet used to set up the proxy.

import Foundation
import Network
import Security
import NIOSSL
import NIO
import X509
import Crypto
import SwiftASN1
import NIOHTTP1
import CryptoKit
import NIOCore
import NIOTLS

class MITMProxyServer: NSObject {
    private let group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
    private let certManager: MITMProxyCertificate
    private var channel: NIO.Channel?

    init(certManager: MITMProxyCertificate) {
        self.certManager = certManager
        super.init()
    }

    func start(port: Int) throws {
        let bootstrap = ServerBootstrap(group: group)
            .serverChannelOption(ChannelOptions.backlog, value: 256)
            .childChannelInitializer { channel in
                channel.pipeline.addHandler(ByteToMessageHandler(HTTPRequestDecoder(leftOverBytesStrategy: .forwardBytes)), name: "ByteToMessageHandler").flatMap {
                    channel.pipeline.addHandler(HTTPResponseEncoder(), name: "HTTPResponseEncoder")
                }.flatMap {
                    channel.pipeline.addHandler(ConnectHandler(certManager: self.certManager, group: self.group), name: "ConnectHandler")
                }
            }
            .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
        
        let boundChannel = try bootstrap.bind(host: "0.0.0.0", port: port).wait()
        guard let nioChannel = boundChannel as? NIO.Channel else {
            fatalError("Failed to cast to NIO.Channel")
        }
        self.channel = nioChannel
    }
    
    @objc static func startProxy() {
        do {
            let proxy = MITMProxyServer(certManager: try MITMProxyCertificate())
            try proxy.start(port: 9443)
        } catch {
            print("Failed to start proxy: \(error)")
        }
    }
}

// MARK: - Handler: CONNECT Request
final class ConnectHandler: ChannelInboundHandler, RemovableChannelHandler {
    typealias InboundIn = HTTPServerRequestPart
    typealias OutboundOut = HTTPServerResponsePart

    private var connectHost: String?
    private let certManager: MITMProxyCertificate
    private let group: EventLoopGroup

    init(certManager: MITMProxyCertificate, group: EventLoopGroup) {
        self.certManager = certManager
        self.group = group
    }
    
    func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
        context.fireUserInboundEventTriggered(event)
    }
    
    func errorCaught(context: ChannelHandlerContext, error: Error) {
        print("TLS error caught: \(error)")
        context.close(promise: nil)
    }

    func channelRead(context: ChannelHandlerContext, data: NIOAny) {
        let part = unwrapInboundIn(data)
        guard case let .head(request) = part, request.method == .CONNECT else {
            context.fireChannelRead(data)
            return
        }

        connectHost = request.uri.components(separatedBy: ":").first

        var responseHead = HTTPResponseHead(version: request.version, status: .ok)
        responseHead.headers.add(name: "Connection", value: "Established")
        context.write(self.wrapOutboundOut(.head(responseHead)), promise: nil)
        context.writeAndFlush(self.wrapOutboundOut(.end(nil))).whenComplete { _ in
            self.upgradeToTLS(context: context)
        }
    }
    
    private func upgradeToTLS(context: ChannelHandlerContext) {
        let pipeline = context.pipeline
        let removeFutures = [
            pipeline.removeHandler(name: "ByteToMessageHandler")
                .recover { _ in },
            pipeline.removeHandler(name: "HTTPResponseEncoder")
                .recover { _ in },
            pipeline.removeHandler(name: "ConnectHandler")
                .recover { _ in }
        ]
        EventLoopFuture.andAllSucceed(removeFutures, on: context.eventLoop).flatMap {
            self.addTLSHandler(context: context)
        }.whenFailure { error in
            print("Failed to remove HTTP handlers or add TLS: \(error)")
            context.close(promise: nil)
        }
    }
    
    private func addTLSHandler(context: ChannelHandlerContext) -> EventLoopFuture<Void> {
        do {
            let certURL = Bundle.main.url(forResource: "id.obc.jp.crt", withExtension: "pem")!
            let keyURL = Bundle.main.url(forResource: "id.obc.jp.key", withExtension: "pem")!
            let niosslCert = try NIOSSLCertificate(file: certURL.path, format: .pem)
            let niosslKey = try NIOSSLPrivateKey(file: keyURL.path, format: .pem)
            
            var tlsConfig = TLSConfiguration.makeServerConfiguration(
                certificateChain: [.certificate(niosslCert)],
                privateKey: .privateKey(niosslKey)
            )
            tlsConfig.applicationProtocols = ["http/1.1"]
            tlsConfig.minimumTLSVersion = .tlsv12
            tlsConfig.maximumTLSVersion = .tlsv13
            
            tlsConfig.certificateVerification = .none
            tlsConfig.renegotiationSupport = .none            
            
            let sslContext = try NIOSSLContext(configuration: tlsConfig)
            let sslHandler = try NIOSSLServerHandler(context: sslContext)
            
            return context.pipeline.addHandler(sslHandler, position: .first)
            .flatMap {
                context.channel.pipeline.configureHTTPServerPipeline()
            }.flatMap {
                context.pipeline.addHandler(HeaderModifierHandler(group: self.group))
            }
            
        } catch {
            return context.eventLoop.makeFailedFuture(error) as EventLoopFuture<Void>
        }
    }
}

// MARK: - Handler: Header Modifier
final class HeaderModifierHandler: ChannelInboundHandler {
    typealias InboundIn = HTTPServerRequestPart
    typealias OutboundOut = HTTPServerResponsePart

    private var requestHead: HTTPRequestHead?
    private var bodyData: ByteBuffer?
    private let group: EventLoopGroup

    init(group: EventLoopGroup) {
        self.group = group
    }
    
    func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
        context.fireUserInboundEventTriggered(event)
    }
    
    func errorCaught(context: ChannelHandlerContext, error: Error) {
        print("TLS error caught: \(error)")
        context.close(promise: nil)
    }

    func channelRead(context: ChannelHandlerContext, data: NIOAny) {
        let part = self.unwrapInboundIn(data)

        switch part {
        case .head(let head):
            self.requestHead = head
        case .body(var body):
            if bodyData == nil {
                bodyData = context.channel.allocator.buffer(capacity: body.readableBytes)
            }
            bodyData?.writeBuffer(&body)
        case .end(let trailers):
            print("Received END. Trailers: \(trailers?.description ?? "nil")")
            if let head = requestHead {
                forwardRequest(head: head, body: bodyData, context: context)
            }
        }
    }

    private func forwardRequest(head: HTTPRequestHead, body: ByteBuffer?, context: ChannelHandlerContext) {
        guard let host = head.headers["host"].first else {
            context.close(promise: nil)
            return
        }

        let remoteHost = host.contains(":") ? host.split(separator: ":")[0] : Substring(host)
        let remotePort: Int = 443

        let clientBootstrap = ClientBootstrap(group: group)
            .channelInitializer { channel in
                do {
                    let tlsConfig = TLSConfiguration.makeClientConfiguration()
                    let sslContext = try NIOSSLContext(configuration: tlsConfig)
                    let handler = try NIOSSLClientHandler(context: sslContext, serverHostname: String(remoteHost))
                    return channel.pipeline.addHandler(handler).flatMap {
                        return channel.pipeline.addHTTPClientHandlers()
                    }
                    
                } catch {
                    return channel.eventLoop.makeFailedFuture(error)
                }
            }
        
        let clientChannel = context.channel
        
        clientBootstrap.connect(host: String(remoteHost), port: remotePort).whenSuccess { remoteChannel in
            
            remoteChannel.eventLoop.execute {
                remoteChannel.pipeline.addHandler(ResponseForwarder(client: clientChannel)).whenComplete { _ in
                    var modifiedHead = head
                    modifiedHead.headers.replaceOrAdd(name: "X-Injected", value: "MITMProxy")
                    
                    remoteChannel.write(NIOAny(HTTPClientRequestPart.head(modifiedHead)), promise: nil)
                    if let body = body {
                        remoteChannel.write(NIOAny(HTTPClientRequestPart.body(.byteBuffer(body))), promise: nil)
                    }
                    remoteChannel.writeAndFlush(NIOAny(HTTPClientRequestPart.end(nil)), promise: nil)
                }
            }
        }
    }
}

I’m confident that the certificate using is valid, because when I test the connection using openssl,
the result shows handshakeCompleted successfully.

The openssl command I used:

openssl s_client -connect id.obc.jp:443 -proxy 192.168.1.8:9443

btw, I’m using ProxyConfiguration to configure the proxy settings for WKWebView.
Here is how I configure it:

@objc func setupProxyConfiguration(for dataSource: WKWebsiteDataStore) -> WKWebsiteDataStore {
        if #available(iOS 18.0, *) {
            let httpProxy = ProxyConfiguration(
                httpCONNECTProxy: NWEndpoint.hostPort(
                    host: NWEndpoint.Host(
                        "127.0.0.1"
                    ),
                    port: NWEndpoint.Port(
                        integerLiteral: 9443
                    )
                )
            )
            dataSource.proxyConfigurations = [httpProxy]
        }
        return dataSource
    }

Please help me resolve this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions