│ Category: General Skills
│ Difficulty: Medium
│ Points: 200
│ Author: Imattas aka Zemi
────────────────────────────────────────────────────────────────────────────────
--[ Challenge Description ]--
│ We intercepted a suspiciously encoded message, but it's clearly hiding a flag.
No encryption, just multiple layers of obfuscation/encoding.
────────────────────────────────────────────────────────────────────────────────
--[ Recon / Initial Analysis ]--
This is a classic multi-layer encoding challenge. The description explicitly
states "no encryption, just multiple layers of obfuscation/encoding," which
tells us the flag has been run through a pipeline of reversible encoding schemes
-- one after another -- and we need to undo each layer in the correct order.
:: Common Encoding Layers in CTF Challenges
The typical encoding schemes seen in picoCTF multi-encoding challenges include:
1. Base64 -- Recognizable by its character set (A-Za-z0-9+/) and optional =/==
padding at the end. Encoded data is roughly 4/3 the size of the original.
2. Hexadecimal (Base16) -- A string consisting only of 0-9a-fA-F characters.
Each pair of hex characters represents one byte.
3. ROT13 -- A Caesar cipher with a rotation of 13. Only affects alphabetic
characters; numbers and symbols remain unchanged.
4. Binary (Base2) -- A string of 0s and 1s, typically in groups of 8 (one byte
per character).
5. Octal -- A string of numbers in groups of 3 (e.g., 160 151 143 157), where
each group is an octal (base-8) representation of an ASCII character.
6. Morse Code -- Dots (.) and dashes (-) separated by spaces or slashes.
7. URL Encoding (Percent Encoding) -- Characters represented as %XX where XX is
the hex value.
8. Decimal ASCII -- A string of space-separated decimal numbers, each
representing an ASCII code.
9. Atbash Cipher -- A simple substitution cipher where A<->Z, B<->Y, C<->X, etc.
10. Base32 -- Uses characters A-Z2-7 with = padding; common in CTF challenges.
────────────────────────────────────────────────────────────────────────────────
--[ Vulnerability / Observation ]--
The flag is wrapped in several reversible encoding layers, each of which is
identifiable by its character set or structure. Because none of the layers are
actual encryption, every one can be peeled off deterministically once
recognized. The key insight is the iterative strategy:
1. Look at the current encoded string and identify which encoding was applied
last (outermost layer).
2. Decode that layer.
3. Look at the result and identify the next layer.
4. Repeat until the plaintext flag picoCTF{...} is revealed.
The solve script automates this by repeatedly attempting all known decodings and
checking if the result looks like a valid next layer or the final flag.
────────────────────────────────────────────────────────────────────────────────
--[ Exploitation / Solution ]--
:: Step 1: Examine the given encoded message
Download or copy the encoded message from the challenge. It will be a string
that looks like one of the encoding formats listed above.
:: Step 2: Identify and peel layers
For example, a typical multi-layer encoding might look like this:
Layer 5 (outermost): Base64
Layer 4: Hex
Layer 3: ROT13
Layer 2: Base64
Layer 1 (innermost): Binary
Original: picoCTF{...}
Working from the outside in:
1. Base64 decode the input
2. Hex decode the result
3. ROT13 the result
4. Base64 decode again
5. Convert binary to ASCII
6. Read the flag
:: Step 3: Use CyberChef or the solve script
CyberChef (https://gchq.github.io/CyberChef/) is excellent for this -- you can
chain operations together in its "Recipe" panel and experiment interactively.
Alternatively, run the automated solve script which tries all common decodings
iteratively.
:: Step 4: Read the flag
Once all layers are peeled, the plaintext picoCTF{...} flag will be revealed.
────────────────────────────────────────────────────────────────────────────────
--[ Full Exploit Script ]--
-- python --
#!/usr/bin/env python3
"""
MultiCode - picoCTF 2026
Category: General Skills | Points: 200
Multiple layers of encoding/obfuscation hide the flag. No encryption --
just encodings like base64, hex, ROT13, binary, octal, morse, base32,
decimal ASCII, URL encoding, and Atbash.
This script peels layers automatically until the picoCTF{...} flag appears.
Usage:
python3 solve.py # interactive -- paste the encoded text
python3 solve.py encoded_message.txt # read from a file
echo "<encoded>" | python3 solve.py # pipe in the data
"""
import base64
import binascii
import re
import string
import sys
import urllib.parse
# ---------------------------------------------------------------------------
# Decoder functions -- each returns decoded text or None on failure
# ---------------------------------------------------------------------------
def try_base64(data: str):
"""Decode Base64. Accepts standard and URL-safe alphabets."""
# Quick sanity: base64 strings are a multiple of 4 (or close with padding)
stripped = data.strip()
if not stripped:
return None
# Allow A-Za-z0-9+/= and whitespace
if not re.fullmatch(r'[A-Za-z0-9+/=\s]+', stripped):
# Try URL-safe variant
if not re.fullmatch(r'[A-Za-z0-9\-_=\s]+', stripped):
return None
try:
cleaned = re.sub(r'\s+', '', stripped)
# Pad if needed
missing_padding = len(cleaned) % 4
if missing_padding:
cleaned += '=' * (4 - missing_padding)
decoded = base64.b64decode(cleaned, validate=True)
text = decoded.decode('utf-8', errors='strict')
# Reject if result is mostly non-printable
if sum(c in string.printable for c in text) / max(len(text), 1) < 0.8:
return None
return text
except Exception:
return None
def try_base32(data: str):
"""Decode Base32."""
stripped = data.strip().upper()
if not stripped:
return None
if not re.fullmatch(r'[A-Z2-7=\s]+', stripped):
return None
try:
cleaned = re.sub(r'\s+', '', stripped)
missing_padding = len(cleaned) % 8
if missing_padding:
cleaned += '=' * (8 - missing_padding)
decoded = base64.b32decode(cleaned)
text = decoded.decode('utf-8', errors='strict')
if sum(c in string.printable for c in text) / max(len(text), 1) < 0.8:
return None
return text
except Exception:
return None
def try_hex(data: str):
"""Decode hexadecimal (with or without 0x prefix, spaces, colons)."""
stripped = data.strip()
# Remove common hex prefixes/separators
cleaned = re.sub(r'(0x|\\x|:|\s+)', '', stripped)
if not cleaned:
return None
if not re.fullmatch(r'[0-9a-fA-F]+', cleaned):
return None
if len(cleaned) % 2 != 0:
return None
try:
decoded = bytes.fromhex(cleaned).decode('utf-8', errors='strict')
if sum(c in string.printable for c in decoded) / max(len(decoded), 1) < 0.8:
return None
return decoded
except Exception:
return None
def try_binary(data: str):
"""Decode binary (groups of 8 bits)."""
stripped = data.strip()
cleaned = re.sub(r'[\s,]+', '', stripped)
if not cleaned:
return None
if not re.fullmatch(r'[01]+', cleaned):
return None
if len(cleaned) % 8 != 0:
return None
try:
chars = [chr(int(cleaned[i:i+8], 2)) for i in range(0, len(cleaned), 8)]
text = ''.join(chars)
if sum(c in string.printable for c in text) / max(len(text), 1) < 0.8:
return None
return text
except Exception:
return None
def try_octal(data: str):
"""Decode octal (space/comma separated groups of 3-digit octal numbers)."""
stripped = data.strip()
parts = re.split(r'[\s,]+', stripped)
if len(parts) < 4:
return None
if not all(re.fullmatch(r'[0-7]{1,3}', p) for p in parts):
return None
try:
text = ''.join(chr(int(p, 8)) for p in parts)
if sum(c in string.printable for c in text) / max(len(text), 1) < 0.8:
return None
return text
except Exception:
return None
def try_decimal(data: str):
"""Decode decimal ASCII (space/comma separated decimal numbers)."""
stripped = data.strip()
parts = re.split(r'[\s,]+', stripped)
if len(parts) < 4:
return None
try:
nums = [int(p) for p in parts]
except ValueError:
return None
if not all(0 <= n <= 127 for n in nums):
return None
# Ensure these look like ASCII values, not just small numbers
if not all(32 <= n <= 126 or n in (9, 10, 13) for n in nums):
return None
try:
text = ''.join(chr(n) for n in nums)
return text
except Exception:
return None
def try_rot13(data: str):
"""Apply ROT13. Always succeeds, so we only use it when other decoders fail
and the result looks more 'flag-like'."""
stripped = data.strip()
if not stripped:
return None
# ROT13 only makes sense on text that has letters
if not any(c.isalpha() for c in stripped):
return None
table = str.maketrans(
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
'NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm'
)
return stripped.translate(table)
def try_atbash(data: str):
"""Apply Atbash cipher (A<->Z, B<->Y, ...)."""
stripped = data.strip()
if not stripped:
return None
if not any(c.isalpha() for c in stripped):
return None
result = []
for c in stripped:
if c.isupper():
result.append(chr(ord('Z') - (ord(c) - ord('A'))))
elif c.islower():
result.append(chr(ord('z') - (ord(c) - ord('a'))))
else:
result.append(c)
return ''.join(result)
def try_url_decode(data: str):
"""Decode URL/percent encoding."""
stripped = data.strip()
if '%' not in stripped:
return None
try:
decoded = urllib.parse.unquote(stripped)
if decoded == stripped:
return None # nothing changed
return decoded
except Exception:
return None
MORSE_CODE_DICT = {
'.-': 'A', '-...': 'B', '-.-.': 'C', '-..': 'D', '.': 'E',
'..-.': 'F', '--.': 'G', '....': 'H', '..': 'I', '.---': 'J',
'-.-': 'K', '.-..': 'L', '--': 'M', '-.': 'N', '---': 'O',
'.--.': 'P', '--.-': 'Q', '.-.': 'R', '...': 'S', '-': 'T',
'..-': 'U', '...-': 'V', '.--': 'W', '-..-': 'X', '-.--': 'Y',
'--..': 'Z', '-----': '0', '.----': '1', '..---': '2',
'...--': '3', '....-': '4', '.....': '5', '-....': '6',
'--...': '7', '---..': '8', '----.': '9',
'.-.-.-': '.', '--..--': ',', '..--..': '?', '.----.': "'",
'-.-.--': '!', '-..-.': '/', '-.--.': '(', '-.--.-': ')',
'.-...': '&', '---...': ':', '-.-.-.': ';', '-...-': '=',
'.-.-.': '+', '-....-': '-', '..--.-': '_', '.-..-.': '"',
'...-..-': '$', '.--.-.': '@', '-.--.-': '}', '-.--.': '{',
}
def try_morse(data: str):
"""Decode Morse code (dots and dashes separated by spaces/slashes)."""
stripped = data.strip()
# Must contain dots and dashes
if '.' not in stripped and '-' not in stripped:
return None
# Should be mostly dots, dashes, spaces, slashes
morse_chars = set('.-/ \t\n')
if sum(c in morse_chars for c in stripped) / max(len(stripped), 1) < 0.9:
return None
try:
# Split words by ' / ' or '/' and letters by space
words = re.split(r'\s*/\s*', stripped)
decoded_words = []
for word in words:
letters = word.strip().split()
decoded_word = ''
for letter in letters:
if letter in MORSE_CODE_DICT:
decoded_word += MORSE_CODE_DICT[letter]
else:
return None # unrecognized morse sequence
decoded_words.append(decoded_word)
return ' '.join(decoded_words)
except Exception:
return None
# ---------------------------------------------------------------------------
# Main decoding loop
# ---------------------------------------------------------------------------
# Ordered by specificity -- more specific decoders first, ROT13/Atbash last
DECODERS = [
("Morse", try_morse),
("Binary", try_binary),
("Octal", try_octal),
("Decimal", try_decimal),
("Hex", try_hex),
("URL", try_url_decode),
("Base32", try_base32),
("Base64", try_base64),
("ROT13", try_rot13),
("Atbash", try_atbash),
]
FLAG_PATTERN = re.compile(r'picoCTF\{[^}]+\}')
def contains_flag(text):
return FLAG_PATTERN.search(text)
def peel_layers(data: str, max_layers: int = 20):
"""Iteratively decode layers until the flag is found or no progress."""
current = data.strip()
layers = []
for iteration in range(max_layers):
print(f"\n{'='*60}")
print(f"Layer {iteration + 1} -- current data ({len(current)} chars):")
preview = current[:120] + ("..." if len(current) > 120 else "")
print(f" {preview}")
# Check for flag
match = contains_flag(current)
if match:
flag = match.group(0)
print(f"\n{'='*60}")
print(f"FLAG FOUND after {len(layers)} decoding layer(s)!")
print(f" Layers applied: {' -> '.join(name for name, _ in layers) if layers else '(none)'}")
print(f" Flag: {flag}")
print(f"{'='*60}")
return flag
# Try each decoder
decoded = False
for name, decoder in DECODERS:
# For ROT13 and Atbash, only try if other decoders failed
# and the result looks more promising
result = decoder(current)
if result is not None and result != current:
if name in ("ROT13", "Atbash"):
# Only accept if the result contains 'pico' or looks more flag-like
if 'pico' in result.lower() or contains_flag(result):
print(f" -> Decoded with: {name}")
layers.append((name, decoder))
current = result
decoded = True
break
else:
print(f" -> Decoded with: {name}")
layers.append((name, decoder))
current = result
decoded = True
break
if not decoded:
# Last resort: try ROT13 and Atbash unconditionally
for name, decoder in [("ROT13", try_rot13), ("Atbash", try_atbash)]:
result = decoder(current)
if result is not None and result != current:
print(f" -> Trying: {name} (speculative)")
layers.append((name, decoder))
current = result
decoded = True
break
if not decoded:
print(f"\n[!] No decoder matched at layer {iteration + 1}.")
print(f"[*] Layers decoded so far: {' -> '.join(name for name, _ in layers)}")
print(f"[*] Final text: {current}")
return None
print(f"\n[!] Reached maximum layers ({max_layers}) without finding flag.")
print(f"[*] Final text: {current}")
return None
def main():
print("=" * 60)
print("MultiCode - picoCTF 2026")
print("Peel multiple encoding layers to reveal the flag")
print("=" * 60)
# Get input from file, stdin, or interactive prompt
if len(sys.argv) > 1:
filepath = sys.argv[1]
try:
with open(filepath, 'r') as f:
data = f.read()
print(f"[*] Read {len(data)} characters from {filepath}")
except FileNotFoundError:
print(f"[!] File not found: {filepath}")
sys.exit(1)
elif not sys.stdin.isatty():
data = sys.stdin.read()
print(f"[*] Read {len(data)} characters from stdin")
else:
print("\n[*] Paste the encoded message below, then press Enter twice (empty line to finish):")
lines = []
try:
while True:
line = input()
if line == '' and lines:
break
lines.append(line)
except EOFError:
pass
data = '\n'.join(lines)
print(f"[*] Read {len(data)} characters")
if not data.strip():
print("[!] No input data provided.")
sys.exit(1)
flag = peel_layers(data)
if flag:
print(f"\n[+] Flag: {flag}")
else:
print("\n[*] Automatic decoding did not find the flag.")
print("[*] Try CyberChef (https://gchq.github.io/CyberChef/) for interactive decoding.")
print("[*] Tips:")
print(" - Look for patterns: all hex chars? base64 padding (=)?")
print(" - dots and dashes? Morse code.")
print(" - 3-digit groups of 0-7? Octal.")
print(" - Groups of 8 binary digits? Binary-to-ASCII.")
print(" - Try ROT13 / ROT47 if nothing else works.")
sys.exit(1)
if __name__ == "__main__":
main()
────────────────────────────────────────────────────────────────────────────────
--[ Key Takeaways ]--
- Multi-layer encoding challenges are not encryption: every layer is reversible
once you recognize its character set or structure.
- Learn to fingerprint encodings on sight: Base64 (A-Za-z0-9+/=), hex (0-9a-f),
binary (8-bit groups), octal (3-digit 0-7 groups), Morse (dots/dashes), URL
(%XX), decimal ASCII, Base32 (A-Z2-7), and substitution ciphers like
ROT13/Atbash.
- Peel from the outermost layer inward, re-inspecting the result after each
decode.
- Automate with a decoder loop that tries each scheme in order of specificity,
leaving ambiguous transforms (ROT13/Atbash) for last.
- CyberChef is ideal for interactive, chainable decoding when you want to
experiment by hand.