//Fibodream
//Fibodream — Vianu CTF — Writeup
>TL;DR
- Flag:
Vianu_CTF{16bc18d0ca25efa5e7c28aeff7f11f21d4de4de9} - Summary: The provided
ciphers.txtcontains one SHA-256 hash per byte (each hash =sha256(single-character)). After decoding the hashes locally to a character-level ciphertext (length 51), the remote service acts as a per-position Caesar-like cipher: letters are shifted mod 26 (using a per-position key), digits are shifted mod 10 (per-position key), and punctuation like{,},_are unchanged. We recovered the keystream by querying the service with known plaintexts ('a'*nfor letters and'0'*nfor digits), then subtracted the keystream to obtain the cleartext flag.
>Files in this challenge
ciphers.txt— the provided list of 51 SHA-256 hashes (one per character).decode_hashes.py— helper script that decodesciphers.txtinto the ciphertext by reversing SHA-256 on single bytes (uses a 0..255 single-byte preimage table).solve.py— end-to-end solver that decodes the hashes locally and queries the remote service to pull the keystream and decrypt the flag.
cypher.txt
44bd7ae60f478fae1061e11a7739f4b94d1daf917982d33b6fc8a01a63f89c21
454349e422f05297191ead13e21d3db520e5abef52055e4964b82fb213f593a1
4c94485e0c21ae6c41ce1dfe7b6bfaceea5ab68e40a2476f50208e526f506080
e3b98a4da31a127d4bde6e43033f66ba274cab0eb7eb1c70ec41402bf6273dd8
a1fce4363854ff888cff4b8e7875d600c2682390412a8cf79b37d0b11148b0fa
d2e2adf7177b7a8afddbc12d1634cf23ea1a71020f6a1308070a16400fb68fde
86be9a55762d316a3026c2836d044f5fc76e34da10e1b45feee5f18be7edb177
44bd7ae60f478fae1061e11a7739f4b94d1daf917982d33b6fc8a01a63f89c21
8ce86a6ae65d3692e7305e2c58ac62eebd97d3d943e093f577da25c36988246b
021fb596db81e6d02bf3d2586ee3981fe519f275c0ac9ca76bbcf2ebb4097d96
4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce
5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9
cd0aa9856147b6c5b4ff2b7dfee5da20aa38253099ef1b4a64aced233c9afe29
de7d1b721a1e0632b7cf04edf5032c8ecffa9f9a08492152b926f1a5a7e765d7
d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35
7902699be42c8a8e46fbbb4501726517e86b22c56a189f7625a6da49081b2451
3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea
ef2d127de37b942baad06145e54b0c619a1f22327b2ebbcfbec78f5564afe39d
aaa9402664f1a41f40ebbc52c9993eb66aeb366602958fdfaa283b71e64db123
189f40034be7a199f1fa9891668ee3ab6049f82d38c68be70f596eab2e1857b7
6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b
2c624232cdd221771294dfbb310aca000a0df6ac8b66b696d90ef06fdefb64a3
62c66a7a5dd70c3146618063c344e531e6d4b59e379808443ce962b3abd63c5a
454349e422f05297191ead13e21d3db520e5abef52055e4964b82fb213f593a1
0bfe935e70c321c7ca3afc75ce0d0ca2f98b5422e008bb31c00c6d7f1f1c0ad6
19581e27de7ced00ff1ce50b2047e7a567c76b1cbaebabe5ef03f7c3017bb5b7
62c66a7a5dd70c3146618063c344e531e6d4b59e379808443ce962b3abd63c5a
d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35
252f10c83610ebca1a059c0bae8255eba2f95be4d1d7bcfa89d7248a82d9f111
6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b
ef2d127de37b942baad06145e54b0c619a1f22327b2ebbcfbec78f5564afe39d
148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940
50e721e49c013f00c62cf59f2163542a9d8df02464efeb615d31051b0fddc326
8e35c2cd3bf6641bdb0e2050b76932cbb2e6034a0ddacc1d9bea82a6ba57f7cf
65c74c15a686187bb6bbf9958f494fc6b80068034a659a9ad44991b08c58f2d2
4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a
4c94485e0c21ae6c41ce1dfe7b6bfaceea5ab68e40a2476f50208e526f506080
4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce
e7f6c011776e8db7cd330b54174fd76f7d0216b612387a5ffcfb81e6f0919683
043a718774c572bd8a25adbeb1bfcd5c0256ae11cecf9f9c3f925d0e52beaf89
5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9
ef2d127de37b942baad06145e54b0c619a1f22327b2ebbcfbec78f5564afe39d
148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940
4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce
1b16b1df538ba12dc3f97edbb85caa7050d46c148134290feba80f8236c83db9
3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d
d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35
ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb
594e519ae499312b29433b7dd8a97ff068defcba9755b6d5d00e84c524d67b06
5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9
d10b36aa74a59bcf4a88185837f658afaf3646eff2bb16c3928d0e9335e945d2
>Step 1 — Local analysis (decode ciphers.txt)
- Look at
ciphers.txt. It has 51 lines — each looks like a hex SHA-256 digest. - Hypothesis: each hash is
sha256(s)for a smalls(likely 1-character ASCII). Confirm by brute-force:
- compute
sha256('0'), sha256('1'), ..., and single ASCII letters/symbols; many matches appear immediately (digits 0..9, letters,{,}etc).
- There was one small gotcha: the file had a UTF‑8 BOM on the first line. Strip it before matching (or use
.lstrip('\\ufeff')).
Command used to inspect (examples):
bash
# quick tests (python):
python3 - <<'PY'
import hashlib
print(hashlib.sha256(b'0').hexdigest())
print(hashlib.sha256(b'a').hexdigest())
PY
Result of decoding ciphers.txt (character-level ciphertext):
Hrvty_KHN{30gi27e5hj18mru9m2f15pwqo4v36s05p3nb2az0}
Length = 51 (which matches the hint that the flag has 51 characters).
decode_hashes.py (what it does)
- Builds a map of SHA256(byte) -> byte for all byte values 0..255.
- Reads
ciphers.txt(strips BOM), reverses each hash using that map, and prints the result.
(See the full script included below.)
>Step 2 — Remote oracle exploration
- Start a quick probe with
nc host portand send short strings to see how the remote encoder responds. - Observations from simple queries:
printf 'a'*51 | nc HOST PORTreturns a 51-character lowercase string; each returned character depends on position — this suggests a position-based shift (a per-position keystream applied to letters).printf '0'*51 | nc HOST PORTreturns a 51-digit string; digits are being shifted mod 10 position-wise.- Braces
{and}and underscores appear to be sent through unchanged.
Reasoning: If the service applies a per-position shift (keystream) to letters and digits, then encrypting a known constant ('a'*n and '0'*n) returns the keystream mapped into letters/digits. Given ciphertext and keystream, plaintext is recovered by subtracting keystream modulo alphabet size.
How we derived decryption formulas
- For letters: ciphertext_char = (plaintext_char + key) mod 26 (in letter positions). So plaintext = (ciphertext - key) mod 26. We get key = (oracle('a') - 'a').
- For digits: ciphertext_digit = (plaintext_digit + key) mod 10. So subtract digit-wise with key obtained from oracle('0').
>Step 3 — Combine & retrieve the flag
- Use
decode_hashes.pyor the identical logic insolve.pyto obtaincipher(the character-level ciphertext). - Query remote using
n = len(cipher):
ks_letters = oracle('a'*n)→ returns lowercase letters, each representing(key + 'a')at that position.ks_digits = oracle('0'*n)→ returns digits representing keys for digit positions.
- For each position i:
- If cipher[i] is lowercase letter: plaintext = (ord(cipher[i]) - ord('a') - (ord(ks_letters[i]) - ord('a'))) mod 26.
- If cipher[i] is uppercase letter: same, but using ord('A').
- If cipher[i] is digit: plaintext = (int(cipher[i]) - int(ks_digits[i])) mod 10.
- Else (e.g.,
{,},_): copy as-is.
Result: Vianu_CTF{16bc18d0ca25efa5e7c28aeff7f11f21d4de4de9}
>Scripts (full source)
decode_hashes.py
python
#!/usr/bin/env python3
import hashlib
from pathlib import Path
def build_sha256_single_byte_reverse_map() -> dict[str, bytes]:
out = {}
for i in range(256):
b = bytes([i])
out[hashlib.sha256(b).hexdigest()] = b
return out
def decode_hash_lines(path: Path) -> bytes:
lines = [ln.strip().lstrip("\\ufeff") for ln in path.read_text().splitlines() if ln.strip()]
rev = build_sha256_single_byte_reverse_map()
decoded = bytearray()
for h in lines:
b = rev.get(h)
if b is None:
raise SystemExit(f"Unknown hash: {h}")
decoded.extend(b)
return bytes(decoded)
def main() -> None:
msg = decode_hash_lines(Path("ciphers.txt"))
print(msg.decode("latin-1"))
print(f"length={len(msg)}")
if __name__ == "__main__":
main()
solve.py (end-to-end)
python
#!/usr/bin/env python3
import argparse
import hashlib
import socket
from pathlib import Path
def build_sha256_single_byte_reverse_map() -> dict[str, bytes]:
return {hashlib.sha256(bytes([i])).hexdigest(): bytes([i]) for i in range(256)}
def decode_hash_lines(path: Path) -> str:
rev = build_sha256_single_byte_reverse_map()
out = bytearray()
for ln in path.read_text().splitlines():
h = ln.strip().lstrip("\\ufeff")
if not h:
continue
b = rev.get(h)
if b is None:
raise SystemExit(f"Unknown hash: {h}")
out.extend(b)
return out.decode("latin-1")
def oracle_encrypt(host: str, port: int, s: str, timeout: float) -> str:
with socket.create_connection((host, port), timeout=timeout) as sock:
sock.sendall((s + "\\n").encode())
data = bytearray()
while True:
chunk = sock.recv(4096)
if not chunk:
break
data.extend(chunk)
return data.decode(errors="replace").strip()
def decrypt_with_keystream(cipher: str, ks_letters: str, ks_digits: str) -> str:
if not (len(cipher) == len(ks_letters) == len(ks_digits)):
raise ValueError("cipher/keystream length mismatch")
kL = [ord(ch) - ord('a') for ch in ks_letters]
kD = [int(ch) for ch in ks_digits]
out = []
for i, ch in enumerate(cipher):
if 'a' <= ch <= 'z':
out.append(chr((ord(ch) - 97 - kL[i]) % 26 + 97))
elif 'A' <= ch <= 'Z':
out.append(chr((ord(ch) - 65 - kL[i]) % 26 + 65))
elif '0' <= ch <= '9':
out.append(str((int(ch) - kD[i]) % 10))
else:
out.append(ch)
return ''.join(out)
def main() -> None:
ap = argparse.ArgumentParser(description="Solve VianuCTF fibodream")
ap.add_argument("--host", default="185.213.240.231")
ap.add_argument("--port", default=5123, type=int)
ap.add_argument("--timeout", default=5.0, type=float)
ap.add_argument("--file", default="ciphers.txt")
args = ap.parse_args()
cipher = decode_hash_lines(Path(args.file))
n = len(cipher)
ks_letters = oracle_encrypt(args.host, args.port, 'a' * n, args.timeout)
ks_digits = oracle_encrypt(args.host, args.port, '0' * n, args.timeout)
plain = decrypt_with_keystream(cipher, ks_letters, ks_digits)
print(plain)
if __name__ == "__main__":
main()
>Reproduction (quick)
- Local decode only:
bash
python3 decode_hashes.py
# prints: Hrvty_KHN{30gi27e5...}
- Full solve (queries remote):
bash
python3 solve.py # default host/port set in script
# prints: Vianu_CTF{16bc18d0...}
If the remote host/port differ, run:
bash
python3 solve.py --host <host> --port <port>
>Why this worked / how the idea came to be
- Seeing that every line was exactly 64 hex characters hinted strongly at SHA-256 digests.
- Many digests matched the SHA-256 of simple single characters (e.g., digits and letters), which suggested these were per-byte SHA-256 preimages (sha256 of a single byte).
- The next step was to look for how the service takes plaintext and turns it into ciphertext. Small probes (
'a'*nand'0'*n) revealed a deterministic, position-wise shift for letters and digits, respectively. That led directly to computing the keystream and subtracting it.
>References
- SHA-2 / SHA-256: https://en.wikipedia.org/wiki/SHA-2
- Caesar cipher / modular shifts: https://en.wikipedia.org/wiki/Caesar_cipher