Description
We intercepted a suspiciously encoded message, but it's clearly hiding a flag. No encryption, just multiple layers of obfuscation. Can you peel back the layers and reveal the truth? Download message.txt.
Setup
cat message.txtSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Download and inspect the message
ObservationThe description promises multiple layers of obfuscation and no encryption. Identify each encoding and strip it in turn, starting with whatever message.txt shows on the surface.Download message.txt and look at the outermost encoding. The message ends with '=' signs and uses only A-Za-z0-9+/ characters, which is the base64 alphabet. The layers in order are: base64 -> hex charcode -> URL encoding -> ROT13.bashcat message.txtWhat didn't work first
Tried: Trying to decode the raw message.txt output directly as hex, since CTF files often use hex encoding.
The outer layer is base64, not hex: mixed case plus trailing equals padding says so. Hex-decoding it produces garbled binary, because the alphabets overlap without matching. Hex uses a narrow subset of what base64 does.
Tried: Running 'file message.txt' or 'xxd message.txt' to detect a hidden binary format before decoding.
file reports ASCII text and xxd shows printable characters throughout, neither of which tells you more than printing the file did. Encoding challenges are always printable text, so identification means recognizing the character set, not finding magic bytes.
Learn more
Encoding transforms data into a different representation without using a secret key. It is reversible by anyone who knows the encoding scheme - no key is required. This is fundamentally different from encryption, which requires a key to reverse. Common encodings include base64, hex, URL encoding, and binary representation. Stacking multiple encodings adds layers that must be peeled in reverse order, but provides no additional secrecy.
The key to identifying the outermost layer is recognising the character set used. Base64 uses
A-Za-z0-9+/=. Base32 usesA-Z2-7=. Hex uses0-9a-fA-Fwith even length. Binary uses only0and1in groups of 8. Morse code uses.,-, spaces, and/as word separators. URL encoding uses%XXsequences. Recognising these patterns at a glance is a core CTF skill.Step 2Peel the four encoding layers in CyberChef
ObservationBase64 gives one continuous hex string, that gives percent-encoded text, and that gives ROT13. Each layer announces the next.Use CyberChef to peel each layer: (1) From Base64, (2) From Hex with delimiter None, since the hex arrives as one unbroken run of digits rather than space-separated bytes, (3) URL Decode, (4) ROT13. After all four operations the flag appears.pythonpython3 << 'EOF' import base64, urllib.parse, codecs # Step 1: base64 decode step1 = base64.b64decode(open('message.txt').read().strip()).decode() # Step 2: from hex (one continuous string, no delimiter) step2 = bytes.fromhex(step1).decode() # Step 3: URL decode step3 = urllib.parse.unquote(step2) # Step 4: ROT13 step4 = codecs.decode(step3, 'rot_13') print(step4) EOFExpected output
picoCTF{nested_enc0ding_...}What didn't work first
Tried: Using 'From Char Codes' in CyberChef with base 10 (decimal) instead of base 16 (hex) for the second layer.
After base64 you have a continuous hex string. Read the pairs as decimal and every character shifts: 70 is 'F' in decimal and 'p' in hex, so the output turns to punctuation. The giveaway is that every number stays in the hex range and only reads sensibly in base 16.
Tried: Applying the four CyberChef operations in reverse order (ROT13 first, then URL decode, then hex charcodes, then base64 last).
Layers come off in reverse of how they went on, outermost first. Apply ROT13 to base64 text and you get nonsense, because that is not what was shifted. The order here is base64, then hex, then URL decoding, then ROT13.
Learn more
Each encoding has a reliable detection signature:
- Base64: length divisible by 4 (with
=padding), charsA-Za-z0-9+/= - Base32: uppercase letters and digits 2-7,
=padding, length divisible by 8 - Hex: even number of chars from
0-9a-fA-F - Binary: only
0and1, length divisible by 8 - Octal: space-separated groups of
0-7digits - Decimal ASCII: space-separated integers in range 32-126
- URL encoding: contains
%followed by two hex digits - Morse code: only
.,-, spaces, and/ - ROT13: looks like English text but shifted; applying ROT13 again reveals the original
CyberChef(gchq.github.io/CyberChef/) is an invaluable tool for multi-layer encoding challenges. Its "Magic" operation automatically detects and applies decoding operations, and you can chain operations visually to peel layers one by one. For automated scripting, the Python script in the next step handles all these cases programmatically.
- Base64: length divisible by 4 (with
Step 3Auto-peel all layers with a script
ObservationFour operations by hand works for this fixed order and breaks the moment the server shuffles or adds layers. A greedy script that identifies each encoding from its character set survives that.Use this script to automatically detect and peel every encoding layer in sequence until the flag is revealed.pythonpython3 - <<'EOF' import base64, re, urllib.parse MORSE = {'.-':'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 peel(s): # Encoding-precedence order (most-restrictive char set first): # binary -> morse -> octal -> decimal -> hex -> URL -> base32 -> base64 -> rot13 # This order is irrecoverable from reading the script alone, so it's # documented here. Reorder and you'll mis-classify (e.g. binary as hex). s = s.strip() c = re.sub(r'0[xX]|[\s:]', '', s) # drop 0x prefixes and separators, not the digits # binary cb = s.replace(' ','').replace(',','') if re.fullmatch(r'[01]+', cb) and len(cb)%8==0: try: t=''.join(chr(int(cb[i:i+8],2)) for i in range(0,len(cb),8)); return 'binary',t except: pass # morse if re.fullmatch(r'[./ \t\n-]+', s) and ('.' in s or '-' in s): try: words=[' '.join(MORSE.get(l,'?') for l in w.strip().split()) for w in s.split('/')] return 'morse',' '.join(words) except: pass # octal parts=re.split(r'[\s,]+', s) if len(parts)>3 and all(re.fullmatch(r'[0-7]{1,3}',p) for p in parts): try: return 'octal',''.join(chr(int(p,8)) for p in parts) except: pass # decimal ASCII try: nums=[int(p) for p in parts] if len(nums)>3 and all(32<=n<=126 for n in nums): return 'decimal',''.join(chr(n) for n in nums) except: pass # hex if re.fullmatch(r'[0-9a-fA-F]+', c) and len(c)%2==0: try: return 'hex', bytes.fromhex(c).decode() except: pass # URL if '%' in s: t=urllib.parse.unquote(s) if t!=s: return 'url', t # base32: pad to multiple of 8 dynamically try: b32_padded = s.upper() + '=' * ((8 - len(s) % 8) % 8) t = base64.b32decode(b32_padded).decode() # isprintable() is too permissive (matches all-whitespace garbage); # require at least one alphanumeric to kill mis-padded false positives. if any(ch.isalnum() for ch in t): return 'base32', t except: pass # base64: pad to multiple of 4 dynamically try: b64_padded = s + '=' * ((4 - len(s) % 4) % 4) t = base64.b64decode(b64_padded).decode() if any(ch.isalnum() for ch in t): return 'base64', t except: pass # rot13 t=s.translate(str.maketrans( 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 'NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm')) if 'pico' in t.lower(): return 'rot13', t return None, s data = open('message.txt').read().strip() for i in range(20): if m:=re.search(r'picoCTF[{][^}]+[}]', data): print('FLAG:', m.group()); break name, data = peel(data) if name: print(f'[{i+1}] {name}: {data[:80]}') else: print('No decoder matched'); break EOFWhat didn't work first
Tried: Running the auto-peel script but it exits with 'No decoder matched' after the URL decode step instead of finding the flag.
The rot13 branch is the only one guarded by content rather than by character set: it returns a result only when the shifted text contains 'pico'. If the innermost layer is some other rotation, ROT5 or ROT21, that guard never fires and the script reports no match even though one shift away sits the flag. Widen that branch to try all 25 rotations before giving up.
Tried: Hardcoding the four-step Python decode (base64 -> hex charcodes -> URL -> ROT13) from step 2 instead of running the greedy auto-peeler when the actual challenge has different or more layers.
The four-step script works for this instance, where the order is known ahead of time. Change the order or add a layer and it produces wrong output with no error at all. The auto-peeler reads each layer off the data instead of assuming.
Learn more
The auto-peeling script implements a greedy decoder: at each step it tries all known encoding schemes in order and applies the first one that succeeds, then repeats on the result. The order matters - binary detection (only 0s and 1s) must come before hex (which is a superset of binary characters); Morse must come before decimal (both use only certain characters). The script terminates when the flag pattern
picoCTF{...}is found or when no decoder matches.Building a robust multi-encoding detector requires handling edge cases: base64 padding (
==or=appended), base32 requiring uppercase and padding to a multiple of 8, hex strings that are also valid base64, and Morse code that contains spaces (letter separator) and slashes (word separator). The script handles these by trying the most specific matchers first and falling back gracefully.This type of challenge teaches encoding literacy: the ability to recognise data representations at a glance. In real incident response and malware analysis, attackers commonly encode payloads in base64 (to bypass text-based filters), hex-encode shellcode (for embedding in source code), and use multiple encoding layers to evade signature-based detection. Being able to peel these layers quickly is an essential analyst skill. See the encodings guide for the full reference.
Interactive tools
- Base64 & Base32 DecoderDecode Base64 and Base32 strings with auto-detection. Multi-layer mode unwraps nested encodings automatically.
- Recipe ChainStack decoders into a pipeline: Base64, hex, ROT, XOR, Morse, URL, Atbash, Vigenère, and more. Magic mode auto-discovers the chain. Bookmark the URL to save it.
- Number Base ConverterConvert numbers between binary, octal, decimal, and hexadecimal instantly. Enter any value and see all four bases update in real time.
Alternate Solution
If you want to decode individual layers by hand, the site has tools for each encoding you will encounter: the Base64 Decoder, Morse Code Decoder, ROT / Caesar Cipher tool, Number Base Converter, and Binary → Hex Converter. Work layer by layer, pasting the output of each tool into the next, until the flag appears.
Flag
Reveal flag
picoCTF{nested_enc0ding_...}
Four stacked layers: base64 -> hex (one unbroken string, so use From Hex with delimiter None, not From Char Codes) -> URL encoding -> ROT13. message.txt decodes through cvpbPGS{arfgrq_rap0qvat_...} at the URL-decode stage, which ROT13 turns into the flag. Shown abbreviated on this page.