简介:面向 iOS 初学者的 HTTP 请求拦截精简 Demo,基于 NSURLProtocol 机制实现,适合想理解网络层拦截原理、调试接口请求或进行基础安全测试的开发者。资源共 114 个文件,压缩包约 135KB,包含 Objective-C 源码(.h/.m)、storyboard 界面、plist 配置以及完整 Xcode 工程文件,目录结构清晰。已有 679 人学习。通过自定义 NSURLProtocol 子类,并在分类的 load 方法中自动注册协议类,URL 加载系统会在请求发出时自动交由该协议对象处理,从而实现对 HTTP 请求的拦截与后续处理。精简后的代码去除了冗余逻辑,便于逐行阅读,读者可快速掌握注册、拦截、回调等核心步骤,并在此基础上扩展为请求日志、参数篡改或 Mock 数据等工具,适合作为 iOS 网络层学习的入门实战素材。
1. 拦截http请求:iOS安全调试的第一块拼图,小白也能落地的切入方式
接手一个老项目,最让人心里没底的不是代码乱,而是不知道它启动之后到底向外发了什么请求。登录接口传没传明文密码、请求头里有没有多余的信息、某个SDK是不是悄悄上报了数据,这些在没看到真实流量之前全是玄学。拦截http请求,是iOS安全分析和日常调试最直接的入口,把App发出的每一个请求看清楚,才能判断哪里有问题、哪里需要加固。标题里强调“为小白用户定制的精简版本”,意味着这篇文章不讲越狱、不搞动态注入,只用系统自带的网络扩展点搭一个最小拦截模块,能看懂能复现。适合iOS开发新手、测试同学,以及刚接触客户端安全分析的人。
2. 原理与选型:拦截http请求的三种主流做法,为什么NSURLProtocol最适合入门
2.1 URL Loading System:iOS网络栈里那个天然的拦截点
要理解拦截http请求,先得知道请求在iOS里是怎么走的。一个网络请求从构造到发送,会经过系统的一个核心组件叫URL Loading System。它负责接管NSURLRequest、分配协议处理器、管理缓存和cookie,最终把数据交给网络层发出去。这个系统在设计上留了一个扩展点:在有请求进来时,先问一遍“有没有自定义的协议处理器想接管”。这个扩展点就是NSURLProtocol。
也就是说,只要App里用的是NSURLSession、NSURLConnection或者基于它们封装的网络库发出的请求,都会经过URL Loading System,都会被NSURLProtocol这个口子看到。这是拦截http请求最稳定的位置,因为它的切入点在业务代码之外,不需要改动任何一行网络调用逻辑。往深了说,这个机制允许开发者把请求在真正发出去之前截住,做修改、记录、甚至直接造假响应返回给调用方,安全测试里经常拿它来做流量审计。
需要明确的是,这里说的“拦截”是正常的开发与调试手段,目的是看清自己的App在做什么,在客户端安全分析里属于很基础的入门操作。理解了这个大前提,再往下看选型就顺了。
2.2 三种拦截方案对比:NSURLProtocol、自定义Session、网络库内置
实际项目里想拿到完整的请求和响应,常见做法有三条路。第一条就是NSURLProtocol,它的优势是拦截范围广、不需要改业务代码,适合快速搭一个全局的调试或审计模块。第二条是自定义NSURLSession,在Session的delegate里统一处理回调,能拿到流量,但要求项目里所有网络请求都走同一个Session实例,老项目很难做到。第三条是依赖网络库内置的拦截器,比如某些第三方网络库自带的拦截器,可以拿到请求过程,但绑定特定库,换库就失效,对新手来说还得先熟悉它的API。
把三者的关键差异整理成一张表,方便对着自己的项目选:
| 方案 | 侵入性 | 覆盖范围 | 上手难度 | 适合场景 |
|---|---|---|---|---|
| NSURLProtocol | 低,注册即可全局生效 | 所有走URL Loading System的请求 | 中,核心方法不多但要理解回调 | 全局日志、安全审计、请求改写 |
| 自定义NSURLSession | 高,需替换所有网络入口 | 仅限使用该Session的请求 | 低,逻辑直观 | 新项目、网络层统一收口 |
| 网络库内置拦截器 | 看具体库 | 仅限该库发起的请求 | 低,调用简单 | 项目已统一使用某网络库 |
从表格能看出来,NSURLProtocol最明显的短板是“覆盖不了所有场景”,比如WKWebView的请求就不走URL Loading System。但它对小白的价值在于:花一个下午跑通最小模块,就能看到全局大部分流量,这个投入产出比是三种方案里最高的。自定义Session要动所有网络调用的入口,老项目里是伤筋动骨的事;网络库内置拦截器虽然简单,但很多项目根本没用第三方网络库。所以从“快速上手、先看到效果”的角度,NSURLProtocol是唯一能让小白在一个周末内跑通并看到真实验收的路线。
2.3 小白的选型结论:什么场景用NSURLProtocol,什么场景别用
结合我自己的踩坑经验,建议按场景来决定。如果目标是“看看这个App到底发了什么请求、响应是什么”,那就用NSURLProtocol,注册一个类就完事,后面想加黑白名单或者日志落盘都方便。如果目标是“对请求做统一的签名、加密、鉴权”,那自定义NSURLSession其实是更清晰的做法,因为你能在Session层集中处理业务逻辑,而不是靠Protocol在底层去改写。如果项目已经全面接入某个网络库,那优先看这个库有没有提供拦截器,没有的话再考虑NSURLProtocol补位。
还有一个容易被忽略的维度:调试期和上线期的策略不一样。开发阶段可以用NSURLProtocol做详细日志,但上线的正式包里通常要把它关掉或只保留崩溃相关的上报,毕竟全量打印请求日志既费性能又容易把敏感信息写进Log。这个判断对想长期维护这套模块的人很重要,建议在一开始就把“调试模式”和“正式模式”的开关留出来,省得后面再改。
3. 用NSURLProtocol跑通最小拦截模块:注册、转发、打日志的完整代码
3.1 注册拦截器:在App启动早期挂好钩子
拦截模块的第一步是让系统知道“我要接管请求”。这个动作在App启动时完成,等第一个网络请求发出之前注册好。最常见的位置是AppDelegate的didFinishLaunching里,或者你自己的SDK初始化方法里。注意只注册一次,重复注册不会报错,但会导致拦截逻辑被触发多次,日志重复。
// AppDelegate.swift func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // 注册自定义的 URLProtocol,让系统在处理请求时先问它 URLProtocol.registerClass(CustomURLProtocol.self) return true }这段代码的核心是URLProtocol.registerClass(_:),传入的是一个继承自URLProtocol的类。注册之后,系统每次准备发起请求,都会调用这个类的canInit(with:)方法询问“你要不要接管”。这里有个建议:注册时机越早越好,最好在业务代码执行前完成,否则启动早期发出的请求可能就拦不到了。
3.2 实现URLProtocol的核心方法:canInit、canonicalRequest、startLoading
接下来是这个模块的主体,新建一个Swift文件,继承URLProtocol,实现五个关键方法。先看整体代码,再逐个拆开解释。
import Foundation class CustomURLProtocol: URLProtocol { // 标记这个请求已经被处理过,避免转发时再次被拦截,造成死循环 private static let handledKey = "CustomURLProtocolHandledKey" // 是否要接管这个请求 override class func canInit(with request: URLRequest) -> Bool { guard let scheme = request.url?.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return false } // 已经被处理过的请求直接放行 if URLProtocol.property(forKey: handledKey, in: request) != nil { return false } return true } // 规范化请求,正常情况下原样返回即可 override class func canonicalRequest(for request: URLRequest) -> URLRequest { return request } // 开始拦截,核心逻辑在这里 override func startLoading() { guard let mutableRequest = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else { return } // 给请求打标记,下次转发时不再被 canInit 拦下 URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutableRequest) // 构造一个真正的请求发出去 let config = URLSessionConfiguration.default let session = URLSession(configuration: config, delegate: self, delegateQueue: nil) let task = session.dataTask(with: mutableRequest as URLRequest) task.resume() } // 停止加载,取消正在进行的任务 override func stopLoading() { // 如果有 dataTask 的引用,在这里 cancel // 这个例子里的 session 和 task 是局部变量,真实项目中建议持有并取消 } }先把canInit(with:)讲透。这个方法是拦截的开关,返回true表示接管这个请求,返回false表示放行。第一个条件是检查scheme,只处理http和https,其他类型如ftp、file直接放过。第二个条件是检查请求里有没有打过标记,这个标记是后面转发时加的,目的是防止自己发出去的请求再次进入canInit,形成死循环。也就是说,canInit里必须同时做“类型过滤”和“自己人放行”两件事,少了任何一个都会出问题。
canonicalRequest(for:)的作用是给请求做标准化处理,简单场景直接返回原请求即可。真正干活在startLoading里:把不可变的URLRequest转成可变版本,打上标记,然后用一个全新的URLSession把请求发出去。这个转发动作是必须的,因为拦截的目的不是让请求消失,而是先看一眼再放行。
既然用了URLSession转发,就需要处理响应回调,把结果回传给原来的调用方,否则调用方等不到数据。这部分代码要遵守URLProtocol的客户端回调规范,核心是调client?.urlProtocol(...)的方法把数据还给系统。
extension CustomURLProtocol: URLSessionDataDelegate { // 收到响应头时转发给客户端 func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSessionResponseDisposition) -> Void) { client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) completionHandler(.allow) } // 收到响应体数据时转发给客户端 func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { client?.urlProtocol(self, didLoad: data) } // 请求完成或出错时通知客户端 func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let error = error { client?.urlProtocol(self, didFailWithError: error) } else { client?.urlProtocolDidFinishLoading(self) } } }这段回调代码的关键是:你替调用方转发了一个请求,拿到响应后不能私吞,必须通过client把数据原样送回URL Loading System,由它通知真正的调用方。didReceive里选择.allow表示允许继续接收数据,.notAllowed表示不缓存。大多数场景下保持这个写法就行,不用改。到这里,一个能正常转发且不泄漏的拦截模块已经成形,剩下的是把日志打出来。
3.3 打印请求详情:把URL、Header、Body完整捞出来
拦截到请求但是看不到内容,那不算真正完成。在startLoading里加一个打印方法,把请求的关键信息输出到控制台。这里最合适的位置是拿到mutableRequest之后、发起转发之前。注意打印不影响请求本身,只是顺路看一眼。
private func logRequest(_ request: URLRequest) { var lines: [String] = [] lines.append("【拦截】\(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "")") // 打印请求头 if let headers = request.allHTTPHeaderFields, !headers.isEmpty { lines.append("Headers: \(headers)") } else { lines.append("Headers: 无") } // 打印请求体,注意 POST 的 body 有时不在 httpBody 里,需要特殊处理 if let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) { lines.append("Body: \(bodyString)") } else { lines.append("Body: 无(或需要从 httpBodyStream 读取)") } print(lines.joined(separator: "\n")) }在startLoading里调用一次就行。httpMethod和url?.absoluteString分别拿到请求方式和完整地址,allHTTPHeaderFields拿到请求头字典,httpBody拿到请求体数据。这里有一个新手一定会踩的坑:POST请求的body打印出来经常是nil,具体原因和解决办法在第4章的系统排查里展开,这里先在日志里留个提示位,方便快速定位问题。
打印对象不限于控制台。想把日志存下来,就在这个方法里把lines数组写进文件,加个时间戳和请求序号就能得到一个简单的流量日志系统。开发阶段用控制台打印最方便,因为Xcode的控制台直接能看到结构化输出,不需要额外搭文件读取的工具。
4. 拦截模块的五个常见坑与排查:从请求死循环到POST body丢失
4.1 https请求拦不到,先检查ATS和证书校验
现象:注册了URLProtocol,http请求能拦到,但https请求一条都看不到,或者拦截到了但转发后App直接报错,页面加载失败。
原因:iOS的ATS(App Transport Security)默认要求https连接满足一定的安全等级,如果请求的服务器证书是自签名的,或者跟系统信任链对不上,URLSession转发时会直接拒绝。另一个原因是部分服务器配置了证书校验,转发时使用的URLSession默认行为无法通过校验。新手最容易遇到的是前者,自签名证书导致请求在转发阶段就被系统掐掉了。
解决:开发调试阶段,在Info.plist里配置临时放行,允许任意加载。注意只建议在Debug环境这么干,正式包一定要收紧,否则等于关闭了iOS的安全传输保护。
<key>NSAppTransportSecurity</key> <dict> <key>NSAllowsArbitraryLoads</key> <true/> </dict>如果加了这段配置仍然拦不到,就要检查是不是用了SSL Pinning,也就是客户端固定了服务器证书。这种情况需要在URLProtocol里实现URLSession的证书challenge回调,对指定的证书做信任处理。开发阶段也可以先把Pinning逻辑暂时关掉,等看清流量再恢复,别在排查流量时被证书拦住。
4.2 请求转发后死循环,App启动就卡死
现象:注册URLProtocol之后,App一启动就卡住,控制台刷出一堆相同的请求日志,CPU占用爆满,甚至直接崩溃。
原因:这是误伤自己人。拦截器在startLoading里用URLSession转发请求,这个新请求又走URL Loading System,又进入canInit(with:),由于没有识别出“这是自己发出去的请求”,再次接管,再转发,再接管,形成一个无限循环。
解决:分两步。第一步,在转发前给请求打一个标记,用URLProtocol.setProperty绑定一个key。第二步,在canInit里检查这个标记,存在就返回false放行。对应第3章的handledKey和那段setProperty代码。这个标记的作用是给请求“验明正身”,避免重复拦截。如果打了标记还是循环,检查一下是不是在copy mutableRequest时把property弄丢了,标记要打在转发用的那个request上,不是打在原始request上。
4.3 POST请求的body神秘消失,日志里只有URL
现象:拦截到POST请求,URL和Header都正常,但打印请求体时发现httpBody是nil,服务端收到的body也是空的,接口报参数缺失。
原因:NSURLProtocol拿到的request,其HTTPBody属性在部分场景下是空的。底层在转发时为了性能或兼容,把请求体放到了HTTPBodyStream里,不会同步给HTTPBody。直接读request.httpBody自然拿不到。这是URLProtocol的已知特性,不是代码写错。
解决:不要依赖httpBody,改成从httpBodyStream里读。常见做法是在startLoading里,把stream的内容读到Data里,重新赋值给请求的httpBody,再打印。
private func extractBody(from request: URLRequest) -> Data? { // 优先直接取 httpBody if let body = request.httpBody { return body } // 拿不到就从 httpBodyStream 读 guard let stream = request.httpBodyStream else { return nil } stream.open() defer { stream.close() } var data = Data() let bufferSize = 1024 var buffer = [UInt8](repeating: 0, count: bufferSize) while stream.hasBytesAvailable { let length = stream.read(&buffer, maxLength: bufferSize) if length <= 0 { break } data.append(buffer, count: length) } return data }这块代码的踩坑点在于stream的读取时机。如果太早去读,stream还没准备好,可能读不到内容;如果太晚,请求已经发完了。我的习惯是在startLoading里拿到mutableRequest后立刻读取,读完再把body拼回去,确保转发出去的请求携带body。如果读完发现data是空的,再用断点在canonicalRequest里看看原始request的状态。
4.4 回调线程不对,偶现崩溃找不到原因
现象:拦截模块上线后,App偶尔崩溃,崩溃堆栈指向UI相关代码或者数组越界,但复现不了,看起来跟网络没关系。
原因:URLSession的delegate回调默认在后台线程执行。你在回调里如果直接操作了UI,或者访问了某个在不安全的多线程环境下使用的对象,就会出现偶现崩溃。这种问题在真机上尤其明显,因为网络回调时机不可控,线程切换频繁。
解决:回调里统一做线程处理。简单的做法是在delegate回调里把数据分发到主线程,或者至少保证UI操作在主线程执行。响应体数据量大的时候,还要注意不要在后台线程累积大量Data造成内存压力。
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { // 数据累加和转发放在后台线程没问题 client?.urlProtocol(self, didLoad: data) // 如果这里要刷新UI,必须切主线程 DispatchQueue.main.async { // 更新UI或状态 } }这个坑的隐蔽之处在于,它不是每次都崩,而是压力大的时候崩。线上用户网络环境各异,回调线程波动比模拟器大得多。给所有delegate回调里的非线程安全操作加一层DispatchQueue.main.async,能省掉后面大量排查时间。
4.5 WKWebView里的H5请求拦不住
现象:App里用WKWebView加载H5页面,页面里发的接口请求在拦截日志里一条都看不到。
原因:WKWebView的网络栈是独立的,不走URL Loading System。它的请求由WebKit进程处理,NSURLProtocol的拦截范围覆盖不到。这是架构层面的限制,不是配置问题。
解决:别在NSURLProtocol上硬刚。想看WKWebView的流量,换专门的网络调试工具或运行时远程调试。对大多数做安全分析的人来说,iOS原生接口的流量更值得关注,H5流量可以单独处理。如果业务上必须统一拦截,可以考虑在JS层做采集,或者改用WKWebView提供的自定义方式来接管网络请求,但这套方案复杂度高,不适合作为小白入门的第一选择。
5. 把拦截模块变成安全分析工具:验证效果与四个扩展方向
5.1 用最小demo工程验证拦截是否生效
写好了模块,先别急着搬到业务项目里,新建一个空的工程跑一遍,确认没问题再迁移。我的验证步骤很简单:新建一个iOS App工程,在AppDelegate里注册CustomURLProtocol,启动后触发一个网络请求,比如在viewDidLoad里用URLSession请求一个公开接口。控制台能看到三段内容——请求URL、请求头、响应数据说明,基本就证明模块生效了。
更严谨的验证方式是断点。在canInit(with:)和startLoading各打一个断点,运行后看调用栈。canInit被触发说明系统确实来问过了,startLoading被触发说明请求确实被接管了。这两步都通过,说明拦截链路本身没问题,后面的排查方向才值得继续。如果发现请求根本没进入startLoading,问题多半出在canInit的过滤条件上,回头检查scheme判断和handledKey判断。
5.2 四个扩展方向:日志落盘、黑白名单、敏感字段标记、交叉验证
第一个方向是日志落盘。控制台打印在真机上不方便看,把拦截到的数据按时间戳写成本地JSON文件,定时取出来分析。这个扩展对线上问题排查尤其有用,相当于给App装了一个自记录的网络账本。第二个方向是黑白名单。在canInit里加一个域名列表判断,只拦截或只放行指定域名,避免生产环境把日志打爆。第三个方向是敏感字段标记。用正则匹配请求体里的token、password等关键字,命中就打警告日志,这对安全审计有直接价值,能快速发现代码里有没有硬编码敏感参数的接口。第四个方向是与外部抓包工具做交叉验证。一边用这套模块记录,一边用独立的网络调试工具对比请求和响应,确认模块转发的数据没有被篡改,两边数据一致,这套模块才能放心交给别人用。
我做iOS项目有个习惯:拿到一个不熟悉的工程,第一件事不是看代码,而是先装这样一个最小的拦截模块,看它启动后到底向外发了哪些请求、带没带不该带的东西。流量不会说谎,很多潜在问题在日志里一眼就能看出来。到现在这个习惯帮我省掉了大量不明不白的联调时间,希望帮到你。
本文还有配套的精品资源,点击获取