Skip to main content

Java Script Kiddie 2 picoCTF 2019 Solution

Analyze a JavaScript image decoder and determine the correct key pair to reconstruct the hidden flag.

Published: April 2, 2026Updated: August 25, 2026

Description

A harder key-based image challenge. The key setup is more complex than part 1.

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1View and analyze the JavaScript
    Observation
    The challenge is client-side and talks about a key-based scheme, so all the scrambling logic is in the page source. Understanding how the key maps to column shifts comes before writing any solver.
    View page source on the challenge. The JavaScript has a more complex key combination scheme than part 1. Carefully read how each pair of key digits maps to a row-shift amount for one of the 16 columns of ciphertext.
    Learn more

    In part 2, the key is 32 characters (16 two-digit values), each two-digit value specifying how many rows to shift in the corresponding column via modulo arithmetic. Read the JS logic precisely before writing your solver.

    Tools like Chrome DevTools' Sources panel let you set breakpoints in the JavaScript and step through it, making it easier to understand the key-to-column-shift mapping.

  2. Step 2Extract and replicate the column-rotation logic
    Observation
    The JS encodes each of the 16 column shifters as a two-digit decimal pair. That is a far smaller search than it looks, provided the fixed 16-byte PNG header (magic plus the IHDR chunk header) is used as a known-plaintext oracle to gate each column independently.
    Transcribe the JavaScript column-rotation logic into Python. For each of the 16 columns i, the key provides a two-digit shifter that determines which source row feeds each output row: result[(j * 16) + i] = bytes[(((j + shifter) % num_blocks) * 16) + i]. The output must start with the 16 fixed bytes every PNG begins with: the magic 89 50 4E 47 0D 0A 1A 0A followed by the IHDR chunk header 00 00 00 0D 49 48 44 52. With 16 columns and 16 known bytes, each column's two-digit shifter can be solved on its own in at most 100 tries.
    python
    python3 << 'EOF'
    hex_str = 'PASTE_HEX_FROM_JS'
    ciphertext = bytes.fromhex(hex_str)
    
    # One known byte per column: PNG magic plus the IHDR chunk header.
    known = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
                   0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52])
    
    LEN = 16
    blocks = len(ciphertext) // LEN
    key = []
    for i in range(LEN):
        for shifter in range(100):          # two decimal digits per column
            # row j = 0 of the reconstructed output
            if ciphertext[((shifter % blocks) * LEN) + i] == known[i]:
                key.append(f'{shifter:02d}')
                break
    print('Key:', ''.join(key))
    EOF
    What didn't work first

    Tried: Reuse the part 1 solver directly by treating the key as 16 single-digit values instead of 16 two-digit pairs.

    Part 2 encodes each shifter as two decimal digits, so the per-column modulus runs 0-99 rather than 0-9. A part-1 solver maps one digit straight to one column shift, so it only ever tries shifts 0-9 and the column-rotation formula is wrong from the start; no candidate reproduces the magic bytes.

    Tried: Check only the first two bytes (0x89 0x50) of the decoded output as the PNG oracle instead of the full fixed header.

    Two bytes only constrain two of the sixteen columns, and the other fourteen shifters stay completely undetermined. Checking all 16 fixed header bytes (89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52) pins one shifter per column, which is what makes the search finish at all.

    Learn more

    If the key is longer or has more combinations to test, consider constraining the search by checking all PNG structural requirements: magic bytes at offset 0, IHDR chunk at offset 8, and valid chunk CRCs. Each additional constraint dramatically reduces the valid key space.

  3. Step 3Render the decoded image
    Observation
    Once a candidate key produces the right PNG magic bytes, the unscrambled output is a valid image file. It is a QR code rather than readable text, so a scanner like zbarimg is what extracts the flag.
    Once the correct key is found, write the rotated/decoded bytes to a .png file and open it to read the flag. The image is a QR code. Scan it with a QR reader (e.g., zbarimg flag.png) to extract the flag string.
    python
    python3 << 'EOF'
    key = 'PASTE_KEY_FROM_ABOVE'                 # 32 digits, two per column
    ciphertext = bytes.fromhex('PASTE_HEX_FROM_JS')
    
    LEN = 16
    blocks = len(ciphertext) // LEN
    decoded = bytearray(len(ciphertext))
    for i in range(LEN):
        shifter = int(key[i * 2:i * 2 + 2])
        for j in range(blocks):
            decoded[(j * LEN) + i] = ciphertext[(((j + shifter) % blocks) * LEN) + i]
    
    with open('flag.png', 'wb') as f:
        f.write(bytes(decoded))
    EOF
    bash
    zbarimg flag.png
    What didn't work first

    Tried: Read the flag directly from the rendered PNG by looking at the image visually instead of scanning it as a QR code.

    The decoded image is a QR code, not a picture of text, so there is no flag to read by eye. The flag is encoded in the QR symbol and needs zbarimg or a phone scanner; opening the image yourself just shows a black-and-white grid.

    Tried: Run zbarimg on the still-scrambled ciphertext hex dump written directly to a .png file before finding the correct key.

    The ciphertext bytes are not a valid PNG and form no recognizable QR pattern, so zbarimg reports scanning 0 barcode symbols. The column rotation has to be fully reversed with the correct key before the file is written; zbarimg cannot undo the scrambling.

    Learn more

    If the image does not open, verify the bytes are correct by running pngcheck flag.png or examining the first bytes with xxd flag.png | head. A valid PNG must have correct chunk lengths and CRC32 values throughout.

Interactive tools
  • QR Code & Barcode DecoderDecode QR codes, Data Matrix, Aztec, PDF417, and 1D barcodes from any uploaded image. Browser-native, no upload to a server.

Flag

Reveal flag

picoCTF{...}

Replicate the JS column-rotation logic in Python, brute-force the 16 two-digit shift values using the fixed 16-byte PNG header as an oracle, and render the image (which is a QR code containing the flag).

Key takeaway

Client-side cryptography is transparent by construction: the scrambling algorithm and any hardcoded data are right there for the attacker to read. When a file format imposes a fixed magic-byte header, those known bytes become a crisp oracle, since each candidate key either reproduces them or does not. That known-plaintext pattern drives plenty of real attacks, from WEP keystream recovery to CBC padding oracles, wherever predictable plaintext structure leaks information about the key.

Related reading

Tools used in this challenge

Where to go next