API请求大小限制是Web服务最基础的防护措施之一,无论是Nginx的client_max_body_size,还是Rack层的请求体校验,目的都是阻止超大请求占用带宽和内存。然而在实际渗透测试和安全审计中,这类限制经常被证明形同虚设。攻击者利用HTTP协议本身的特性,可以在不违反服务器配置的情况下把数据悄然送入后端。本文将从协议原理出发,结合Ruby代码演示几种典型的绕过实现,最后给出对应的服务端加固方案,帮助读者完整理解攻防两侧的技术细节。

请求大小限制的校验原理与失效条件
绝大多数大小限制只检查请求头中的Content-Length字段。以Nginx为例,当client_max_body_size设置为1m时,Nginx会在收到请求头后立即读取Content-Length的值,超过阈值直接返回413状态码,请求体根本不会被转发。
这套机制的问题在于,Content-Length并非唯一合法的请求体声明方式。HTTP/1.1引入的分块传输编码允许发送方不预先声明总长度,而是把数据切分成若干块,每块前面标注本块的字节数,最后以一个零长度块表示结束。很多中间件在校验阶段看不到总长度,只能放行,等数据全部接收完毕时内存已经被占用了。
此外,一些代理和框架在解析异常时的宽容行为也会造成校验失效。例如Content-Length与Transfer-Encoding同时存在时,不同组件对优先级的解读不一致,这种请求走私的变体同样可以绕过大小检查。理解这些失效条件,是构造绕过payload的基础。
分块传输编码绕过的Ruby实现
下面用Ruby标准库Net::HTTP演示如何发送一个分块编码请求。默认情况下Net::HTTP遇到大请求体会自动启用分块编码,我们也可以手动控制分块过程。
require 'net/http'
require 'uri'
uri = URI.parse('http://target.ippipp.com/upload')
http = Net::HTTP.new(uri.host, uri.port)
# 构造一个远超限制的请求体
payload = "A" * 5_000_000
request = Net::HTTP::Post.new(uri.request_uri)
request['Transfer-Encoding'] = 'chunked'
request['Content-Type'] = 'application/octet-stream'
# 使用流式写入,Net::HTTP会自动分块发送
request.body_stream = StringIO.new(payload)
response = http.request(request)
puts response.code
这段代码的关键在于body_stream与Transfer-Encoding: chunked的组合。由于请求头中没有Content-Length,只检查该字段的中间件会认为这是一个无体的请求或者长度未知的小请求,从而放行。如果目标服务器后端的Rack应用在读取request.body时才真正接收数据,限制就被彻底绕过了。
更精细的手动分块可以借助底层socket实现,逐块写入并控制每块大小与发送节奏,还能配合慢速发送测试服务端的超时与内存回收策略。安全审计中常用这种方式验证应用是否存在Slowloris式的资源耗尽风险。
分片重组与压缩载荷绕过
当目标接口本身允许提交数据时,攻击者还可以把大载荷切成多个合法的小请求,在服务端重新拼装。典型场景是文件上传接口支持分片,或者API允许客户端分页提交JSON片段。
require 'net/http'
require 'json'
require 'zlib'
uri = URI.parse('http://target.ippipp.com/api/chunk')
data = File.read('large_payload.bin')
# 压缩 + 分片,每片控制在限制以内
compressed = Zlib::Deflate.deflate(data)
chunk_size = 512 * 1024
chunks = compressed.each_slice(chunk_size).to_a
chunks.each_with_index do |chunk, i|
req = Net::HTTP::Post.new(uri.request_uri)
req['Content-Type'] = 'application/json'
req.body = {
index: i,
total: chunks.size,
data: [chunk].pack('m0') # base64编码
}.to_json
Net::HTTP.new(uri.host, uri.port).request(req)
end
压缩本身也是一种独立手段。10MB的明文JSON经过gzip压缩后可能只有几百KB,如果服务端限制的是传输大小而不是解压后的大小,压缩载荷就能轻松通过校验,随后在内存中膨胀成原来的体积,形成解压炸弹。攻击面在解压逻辑,而不在传输层。
分片重组的前提是服务端存在合并逻辑,因此审计时要重点检查分片接口是否校验了总大小上限、是否限制了分片数量、以及是否对合并后的最终数据做了二次校验。这三点任何一处缺失,都会让前端的单请求限制失去意义。
服务端加固方案与Rack中间件实现
防御的核心原则只有一条:校验必须发生在真实数据流上,而不是请求头的声明值上。下面是一个Ruby Rack中间件示例,它在读取请求体时逐块计数,无论客户端用哪种编码方式,超限即刻中断连接。
class BodySizeLimiter
LIMIT = 2 * 1024 * 1024 # 2MB
def initialize(app)
@app = app
end
def call(env)
body = env['rack.input']
total = 0
chunks = []
while (chunk = body.read(64 * 1024))
total += chunk.bytesize
if total > LIMIT
return [413, {'Content-Type' => 'text/plain'}, ['Payload too large']]
end
chunks << chunk
end
env['rack.input'] = StringIO.new(chunks.join)
@app.call(env)
end
end
use BodySizeLimiter
run MyApp
除了应用层的流式校验,Nginx配置也需要同步调整。首先要显式禁用或严格限制分块编码:chunked_transfer_encoding off适用于不需要分块上传的场景;必须支持分块时,则要配合client_body_buffer_size和request_pool_size控制内存占用,并用proxy_request_buffering on确保Nginx先完整接收再转发。
对于解压炸弹风险,服务端解压前要检查压缩头中声明的原始大小,Ruby中可以这样防御:
require 'zlib'
MAX_DECOMPRESSED = 10 * 1024 * 1024
def safe_inflate(compressed)
inflater = Zlib::Inflate.new(Zlib::MAX_WBITS)
result = +''.b
offset = 0
while offset < compressed.bytesize
chunk = compressed.byteslice(offset, 64 * 1024)
offset += chunk.bytesize
result << inflater.inflate(chunk)
return nil if result.bytesize > MAX_DECOMPRESSED
end
result
rescue Zlib::Error
nil
ensure
inflater.close rescue nil
end
最后建议把大小限制纳入纵深防御体系:网络层的Nginx限制、应用层的Rack中间件校验、业务层的字段长度验证三者缺一不可,同时配合Rack::Attack对高频分片请求做速率限制,封堵分片重组的攻击路径。只有当每一层都基于真实数据流做判断,而不是信任客户端的声明时,大小限制才能真正发挥作用。