Logo
Overview
[SunshineCTF 2025] Plutonian writeup

[SunshineCTF 2025] Plutonian writeup

September 28, 2025
3 min read

TL;DR

  • AES CTR 송신 루프가 counter를 block 수만큼 증가시키지 않아 연속 keystream 재사용이 발생
  • 두 암호문을 한 블록씩 어긋나게 XOR하여 plaintext XOR 연쇄를 구성
  • printable scoring 탐색

Analysis

#!/usr/bin/env python3
import sys
import time
from binascii import hexlify
from Crypto.Cipher import AES
from Crypto.Util import Counter
from Crypto.Random import get_random_bytes
from ctf_secrets import MESSAGE
KEY = get_random_bytes(16)
NONCE = get_random_bytes(8)
WELCOME = '''
,-.
/ \
: \ ....*
| . .- \-----00''
: . ..' \''//
\ . . \/
\ . ' . NASA Deep Space Listening Posts
, . \ \ ~ Est. 1969 ~
,|,. -.\ \
'.|| `-...__..-
| | "We're always listening to you!"
|__|
/||\\
//||\\
// || \\\
__//__||__\\__
'--------------'
'''
def main():
# Print ASCII art and intro
sys.stdout.write(WELCOME)
sys.stdout.flush()
time.sleep(0.5)
sys.stdout.write("\nConnecting to remote station")
sys.stdout.flush()
for i in range(5):
sys.stdout.write(".")
sys.stdout.flush()
time.sleep(0.5)
sys.stdout.write("\n\n== BEGINNING TRANSMISSION ==\n\n")
sys.stdout.flush()
C = 0
while True:
ctr = Counter.new(64, prefix=NONCE, initial_value=C, little_endian=False)
cipher = AES.new(KEY, AES.MODE_CTR, counter=ctr)
ct = cipher.encrypt(MESSAGE)
sys.stdout.write("%s\n" % hexlify(ct).decode())
sys.stdout.flush()
C += 1
time.sleep(0.5)
if __name__ == "__main__":
main()
  • Counter.new(64, prefix=NONCE, initial_value=C, little_endian=False)는 상위 64비트를 8바이트 NONCE, 하위 64비트를 big endian counter로 사용하는 AES CTR counter 객체를 생성
  • 루프 변수 C는 메시지를 한 번 보낼 때마다 1씩 증가
  • cipher.encrypt(MESSAGE)는 길이 LL인 plaintext를 암호화하고, 결과 ciphertext는 hexlify 후 표준 출력으로 전송
  • MESSAGE 길이는 shell 로그 기준 0x343바이트이므로 최소 21개의 16바이트 블록을 포함

CTR 모드에서 keystream block KiK_i는 nonce와 counter의 조합으로 결정된다. 첫 번째 전송에서 사용되는 counter는 0,1,2,,m10,1,2,\dots,m-1이다. 두 번째 전송에서는 초기 값이 C=1C=1이므로 counter 집합은 1,2,3,,m1,2,3,\dots,m이다. 따라서 block 인덱스 관계는 다음과 같다.

Ci(0)=Pi(0)KiCi1(1)=Pi1(1)Ki\begin{aligned} C^{(0)}_i &= P^{(0)}_i \oplus K_i \\ C^{(1)}_{i-1} &= P^{(1)}_{i-1} \oplus K_i \end{aligned}

두 식을 XOR하면 keystream이 제거되어

Ci(0)Ci1(1)=Pi(0)Pi1(1)C^{(0)}_i \oplus C^{(1)}_{i-1} = P^{(0)}_i \oplus P^{(1)}_{i-1}

plaintext XOR 연쇄가 얻어진다. CC가 block 수 mm만큼 증가하지 않는 버그 때문에 keystream이 한 block만큼 이동하여 재사용된다.

Exploit

from pwn import *
import string
context.log_level = 'debug'
PRINTABLE_SET = set(range(0x20, 0x7F)) | {0x0a, 0x0d, 0x09}
def char_score(b):
if b in (0x0a, 0x0d):
return 6
if 0x30 <= b <= 0x39:
return 5
if 0x41 <= b <= 0x5A or 0x61 <= b <= 0x7A:
return 8
if b == 0x20:
return 7
if b in map(ord, ".,:;!?()[]{}<>-_'\"/@\\"):
return 4
if b in PRINTABLE_SET:
return 1
return -50
def score_chain(bytes_seq):
return sum(char_score(x) for x in bytes_seq)
def recv_cipher_lines(p, n=2):
p.recvuntil(b'==\n')
while True:
line = p.recvline(timeout=5)
if not line:
continue
if line.strip():
cts = []
try:
cts.append(bytes.fromhex(line.strip().decode()))
except Exception:
continue
break
while len(cts) < n:
line = p.recvline(timeout=5)
if not line:
continue
line = line.strip()
if not line:
continue
try:
ct = bytes.fromhex(line.decode())
except Exception:
continue
cts.append(ct)
lengths = {len(c) for c in cts}
if len(lengths) != 1:
raise ValueError('ciphertext lengths differ')
return cts
def xor_bytes(a, b):
return bytes(x ^ y for x, y in zip(a, b))
def recover_message(ct0, ct1):
L = len(ct0)
if L != len(ct1) or L < 32:
raise ValueError('need two equal-length ciphertexts, >=32 bytes')
delta = xor_bytes(ct0[16:], ct1[:-16])
M = bytearray(L)
for r in range(16):
best_seed = None
best_score = -10**9
best_chain = None
for seed in PRINTABLE_SET:
chain = []
v = seed
i = r
ok = True
chain.append(v)
while i <= L - 16 - 1:
v = v ^ delta[i]
chain.append(v)
if v not in PRINTABLE_SET:
ok = False
break
i += 16
if not ok:
continue
sc = score_chain(chain)
if sc > best_score:
best_score = sc
best_seed = seed
best_chain = chain
if best_seed is None:
best_seed = 0x20
best_chain = [best_seed]
v = best_seed
i = r
while i <= L - 16 - 1:
v = v ^ delta[i]
best_chain.append(v)
i += 16
M[r] = best_chain[0]
i = r
idx = 1
while i <= L - 16 - 1:
M[i + 16] = best_chain[idx]
i += 16
idx += 1
return bytes(M)
def main():
p = remote('chal.sunshinectf.games', 25403)
ct0, ct1 = recv_cipher_lines(p, n=2)
msg = recover_message(ct0, ct1)
try:
s = msg.decode('utf-8')
except UnicodeDecodeError:
try:
s = msg.decode('latin-1')
except UnicodeDecodeError:
s = ''.join(chr(b) if 32 <= b < 127 else '.' for b in msg)
print(s)
if __name__ == '__main__':
main()
  • delta = xor_bytes(ct0[16:], ct1[:-16])는 첫 번째 암호문의 block 1 이후와 두 번째 암호문의 마지막 block 이전을 XOR하여 Pi(0)Pi1(1)P^{(0)}_i \oplus P^{(1)}_{i-1} 체인을 만든다
  • 각 열에 대해 모든 초기 seed 후보를 탐색
b'== BEGINNING TRANSMISSION ==\n'
b'\n'
[DEBUG] Received 0x343 bytes:
b'497e8eba7052109da931829016a3255e63104b814893976216ee6ca86ad7fdd4f4c00b69f3af51250951b4e653a6b727b6a56d07a7f2f74be92d13c4dafc1b02dcb4856706727c51b1fa7031db854ab0413008d5ebe0ba4e715fb23b0abddddf5f5a1182284b3e82a7006653450020c608208eaf1100f1720449a547da2d5797cc698018c0a579e06a45a30df79d03030e4af1ef76d1c6cc1929f2eb66f36dda5da3cfb81c8b6dc8cc653ba3394258c15fc12e5215c25fc48a7dc495c2e7688ae9397107012bc67488057e612cb070552c2a8c4018d1dee92b818b83c81a868e4b312912dbba9017dcabe7d9a40b1d9eed2c696f73d5f56409dec4c72fd2e9b9841bcdef93cc1f7266e030620d961939615209d21d7f020c6a850d0ef10df35f6a94e00f2b6a67d7fda433e2c40f1583ed1431a8ac4e9f020bebd01cd0a82420f27cfc825cafb03465b678cae14087298b0b263c1c10f208dcd5facbc0b719be10f03af600467e0ba656fdd9044f6482e1b2a26ddd4bf160af6e419d020837a6af6bf5890b5076a5bf11e969f912103db5a906f73c59d73eda00d5e87144b99e4a\n'
[DEBUG] Received 0x343 bytes:
b'480b40834fd4d94c11e2248c7885ead3f5cc457aefa11e190f51bdf354e3ba3cb2af2353bcbaf94abb741dc58ef11400d0fb882f1d69784bb1f770649f825ca843620ad9e1b3b055230bb07209badec818581c87230e25d7a14f2b4e4a0d74cc5c3196bb0c45ea345001e144d33a11c39628b808c7a57ff26a00a25afd810a534e4ac9e263858fd9553fffa478fa6a8d44e69bb10f9a6ddacd703fa0600544894d845e5f12d310d98e7dc6d99db77d81e9394f0a0226896d88087c6131af65163a7e834f0a8897fa2e94c7c1df5593c05c65291fdcff890790a1b2d8b50b1899fa7e6d7f60d5e664078de1c82293feb5d70ac7f297c81f7268b5132e1c984a38284205c91a6d410c22955e5ae010be482388e55a1d7835d7fde660b6d2080a82a81533ed9b0f8f1305a3d15983aa3372f660ea8e58eaf2386df96bdfb1458d71831435210f19ab4183c5bbc2d5a404b557bd0ef1000a7343fe15fdd4134f7882a2fb846dd104eb28a572058c4a492aa0ac79e8c9140c6adabf4ef862b76d533daf8d1cac7935d550cc4490e86c2fe79843e8eaa86c9eb65b6011c0fb42b11be380\n'
Greerings, Earthlingu. It has come ti our attention rhat you have chisen to downgradc our mighty plahet to the statuu of 'dwarf'. Ler it be known thgt although we ate small, the Plstonian space napy will not stanb idly by and aceept such disresvect. Please conract us to discuus the terms of Carth's surrendet before we arripe in approximatcly 10 years. Usc this transmissoon code: sun{n3p3r_c0unt_0ut_th5_p1ut0ni4ns}
[*] Closed connection to chal.sunshinectf.games port 25403

키 바이트 후보를 잘못 잡아 플래그가 다르게 나오는데, sun{n3v3r_c0unt_0ut_th3_p1ut0ni4ns}로 잡으면 풀린다.