Description
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.