HyperSonic CTF 2026 prob 1 Writeup
HyperSonic CTF 2026 prob 1 WriteupHyperSonic CTF 2026 prob 1 WriteupHyperSonic CTF 2026 prob 1 문제 풀이HyperSonic CTF 2026 prob 1 writeup
Contents
개요
prob 1은 일부 구간이 난수로 덮인 레코드 데이터에서 flag를 복원하는 reversing 문제입니다. 제공된 실행 파일은 원본을 복사한 뒤 일정한 주기마다 일부 구간을 난수로 덮어씁니다.
확인한 조건은 다음과 같습니다.
- 카테고리:
reversing - 목표: 난수로 덮이지 않은 레코드 데이터에서 flag가 들어 있는 구조를 찾아 복원합니다.
먼저 덮어쓰기 규칙을 확인합니다. 어떤 위치가 난수로 바뀌었고 어떤 위치가 그대로 남았는지 계산한 뒤, 남은 데이터에서 파일 시스템 구조를 복구합니다.
문제 분석
풀이에서 사용하는 용어는 다음과 같습니다.
ENC: 한 번에 난수로 덮어쓰는 길이입니다. 값은524288바이트입니다.SKIP: 각 주기에서 그대로 두고 건너뛰는 길이입니다. 값은9961472바이트입니다.STRIDE: 덮어쓰기 주기입니다.SKIP + ENC이므로10485760바이트입니다.frame: 레코드 데이터 안에 들어 있는 raw LZO1X 압축 스트림입니다.directory block: ext4 디렉터리 엔트리가 들어 있는4096바이트 블록입니다.inode: ext4 파일 시스템에서 파일이나 디렉터리를 가리키는 번호입니다.name: 디렉터리 엔트리의 이름입니다. 이 문제에서는 한 글자짜리 디렉터리 이름들이 flag 메시지를 구성합니다.
PyInstaller로 묶인 실행 파일의 Python 바이트코드를 보면 다음과 같은 흐름입니다.
ENC = 524288
SKIP = 9961472
STRIDE = SKIP + ENC
BUF = 1048576
def overwrite_pattern(f, offset, length):
f.seek(offset)
chunk = os.urandom(BUF)
remaining = length
while remaining > 0:
n = min(BUF, remaining)
f.write(chunk[:n])
remaining -= n
def make_output_name(path):
if not path.endswith(".records") and not path.endswith(".record_ids"):
raise ValueError("not a .records or .record_ids file: " + path)
return path + ".ENC"
def encrypt(path):
out = make_output_name(path)
shutil.copy2(path, out)
size = os.path.getsize(out)
with open(out, "r+b") as f:
offset = 0
while offset < size:
offset += SKIP
if offset >= size:
break
length = min(ENC, size - offset)
overwrite_pattern(f, offset, length)
offset += ENC
return outoverwrite_pattern은 os.urandom으로 만든 데이터를 그대로 씁니다. 따라서 덮인 구간의 원래 내용은 복구할 수 없습니다. 다만 덮어쓰기 위치가 고정되어 있으므로 남아 있는 구간을 선별할 수 있습니다.
난수로 덮인 위치는 다음 조건으로 계산됩니다.
9961472 <= offset % 10485760 < 10485760즉 10MiB마다 앞의 9.5MiB는 남아 있고, 마지막 512KiB는 난수로 바뀝니다.
레코드 데이터 중 난수로 덮이지 않은 구간에는 raw LZO1X 스트림이 있습니다. 정상적으로 압축 해제되는 스트림은 8192바이트를 출력했고, 이 출력은 4096바이트 ext4 블록 두 개로 나눌 수 있었습니다.
ext4 디렉터리 엔트리는 다음 구조로 파싱했습니다.
inode : uint32
rec_len : uint16
name_len : uint8
file_type : uint8
name : name_len 바이트디렉터리 블록으로 인정하려면 rec_len이 블록 범위를 벗어나지 않아야 하고, 첫 두 엔트리가 .와 ..이어야 합니다. 이 조건을 만족하는 블록들을 모으면 디렉터리 inode와 자식 엔트리의 관계를 복구할 수 있습니다.
복구된 디렉터리 중 루트 근처의 흐름은 다음과 같습니다.
root
home -> inode 16
home
kyouki -> inode 121
home/kyouki
.bash_logout
.profile
.bashrc
.ssh
.cache
f -> inode 2548
.sudo_as_admin_successful
.bash_history여기서 f가 눈에 띕니다. 복구된 파일 시스템의 home/kyouki/f부터 따라가면 각 디렉터리에는 자식 디렉터리가 하나씩만 있고, 그 이름이 모두 한 글자입니다.
핵심 아이디어
풀이 방향은 아직 남아 있는 압축 블록을 모아 파일 시스템의 일부 구조를 재구성하는 것입니다.
실행 파일 분석으로 난수로 덮인 위치를 정확히 계산할 수 있습니다. 이 위치를 제외하고 raw LZO1X 스트림을 시도하면, 일부 스트림에서 ext4 디렉터리 블록이 나옵니다. 각 디렉터리 블록은 자신의 inode를 . 엔트리에 담고 있으므로, 이를 기준으로 inode -> children 형태의 사전을 만들 수 있습니다.
그 다음에는 복구된 파일 시스템의 home/kyouki/f에서 시작하는 디렉터리 체인을 따라갑니다. 여기서는 디렉터리 이름 자체가 메시지입니다. 자식이 하나뿐인 디렉터리를 계속 따라가며 name을 이어 붙이면 flag is ... 형태의 문장이 만들어집니다.
풀이 과정
Step 1. 덮어쓰기 구간 계산
실행 파일은 대상 파일을 복사한 뒤 SKIP만큼 이동하고, 그 위치부터 최대 ENC바이트를 난수로 덮습니다. 이후 다시 ENC만큼 이동한 상태에서 같은 작업을 반복합니다.
따라서 어떤 물리 offset이 난수로 덮였는지는 다음 함수로 판정합니다.
ENC = 524288
SKIP = 9961472
STRIDE = 10485760
def corrupt(offset):
rel = offset % STRIDE
return SKIP <= rel < SKIP + ENC이 함수로 스캔 중 건너뛸 영역이 정해집니다. 난수로 덮인 위치에서는 압축 해제가 우연히 일부 맞더라도 결과를 믿을 수 없기 때문에, solver에서는 먼저 corrupt(offset)으로 제외했습니다.
Step 2. raw LZO1X 스트림 복원
레코드 데이터는 고정 크기 슬롯 안에 압축 스트림이 들어 있는 형태로 보였습니다. 스캔은 0x4000바이트 단위 슬롯에서 시작하고, 내부에서는 0x200바이트 간격으로 후보 offset을 잡았습니다.
후보 위치에서 raw LZO1X 압축 해제를 시도했을 때 다음 조건을 만족하면 유효한 frame으로 보았습니다.
압축 해제가 EOF marker에서 끝난다.
출력 길이가 8192바이트다.8192바이트 출력은 ext4 블록 두 개로 나눌 수 있습니다. 각 절반을 directory block 후보로 두고, ext4 디렉터리 엔트리 형식에 맞는지 검사했습니다.
Step 3. ext4 디렉터리 관계 수집
디렉터리 블록을 파싱하면 . 엔트리에서 현재 디렉터리의 inode를 얻고, 나머지 엔트리에서 자식 목록을 얻습니다.
entries[0] = "." -> 현재 디렉터리 inode
entries[1] = ".." -> 부모 디렉터리 inode
entries[2:] -> 자식 엔트리같은 inode에 대한 블록이 여러 번 발견될 수 있으므로, 자식 목록이 더 많이 복구된 쪽을 남겼습니다. 이 과정을 거치면 dirs[inode] = children 형태의 관계를 얻습니다.
이 관계에서 루트 디렉터리 아래 home, 그 아래 kyouki를 확인했습니다. 이후 home/kyouki의 자식 중 f가 flag 메시지의 시작점으로 보였습니다.
Step 4. 한 글자 디렉터리 체인 추적
f의 inode부터 시작해 자식 디렉터리를 따라가면 각 단계마다 자식이 하나뿐입니다. 이름을 이어 붙이면 다음과 같은 체인이 됩니다.
f/l/a/g/ /i/s/ /H/S/{/d/0/c/0/c/b/4/6/0/7/2/0/e/6/f/2/1/d/e/1/d/c/c/f/e/b/3/4/c/a/b/9/1/d/1/c/4/9/9/0/d/b/f/3/4/5/2/f/5/a/9/a/2/0/9/5/b/9/4/3/0/f/a/e/}슬래시는 경로 구분을 보여주기 위해 넣은 표기입니다. 실제로는 각 디렉터리의 name을 그대로 이어 붙입니다.
flag is HS{d0c0cb460720e6f21de1dccfeb34cab91d1c4990dbf3452f5a9a2095b9430fae}Exploit / Solver
최종 solver는 다음 순서로 동작합니다.
- 난수로 덮인
offset을 건너뜁니다. - 후보
offset에서 raw LZO1X 압축 해제를 시도합니다. 8192바이트 출력의 각4096바이트 절반을 ext4 디렉터리 블록으로 파싱합니다.home/kyouki/f의inode를 찾습니다.- 자식이 하나뿐인 디렉터리 체인을 따라가며 이름을 이어 붙입니다.
풀이에 필요한 주요 루틴은 다음과 같습니다. decompress는 raw LZO1X 스트림을 풀어 output, consumed, eof를 돌려주는 함수입니다.
from __future__ import annotations
import re
import struct
import sys
from lzo1x_raw import decompress
SLOT = 0x4000
SECTOR = 0x200
SCAN_START = 0x6700000
SCAN_END = 0x6810000
ENC = 524288
SKIP = 9961472
STRIDE = 10485760
def corrupt(offset: int) -> bool:
rel = offset % STRIDE
return SKIP <= rel < SKIP + ENC
def align_sector(n: int) -> int:
return (n + SECTOR - 1) & ~(SECTOR - 1)
def valid_name(name: bytes) -> bool:
if not name or name in (b".", b".."):
return True
if b"\x00" in name or b"/" in name:
return False
return all(32 <= b < 127 for b in name)
def parse_dir_block(block: bytes):
entries = []
pos = 0
while pos + 8 <= len(block):
inode, rec_len, name_len, file_type = struct.unpack_from("<IHBB", block, pos)
if rec_len < 8 or rec_len % 4 or pos + rec_len > len(block):
return None
if inode:
if name_len > rec_len - 8 or file_type > 7:
return None
name = block[pos + 8 : pos + 8 + name_len]
if not valid_name(name):
return None
entries.append((inode, file_type, name))
else:
if not (rec_len == 12 and name_len == 0 and file_type in (0, 0xDE)):
return None
pos += rec_len
return entries if pos == len(block) else None
def collect_directories(records_path: str):
dirs = {}
with open(records_path, "rb") as f:
slot = SCAN_START - (SCAN_START % SLOT)
while slot < SCAN_END:
if corrupt(slot):
slot += SLOT
continue
f.seek(slot)
buf = f.read(SLOT + 0x3000)
rel = 4
while rel < SLOT and rel + 16 <= len(buf):
offset = slot + rel
if (
offset < SCAN_START
or offset >= SCAN_END
or corrupt(offset)
or buf[rel : rel + 16].count(0) == 16
):
rel += SECTOR
continue
try:
res = decompress(buf[rel:], max_out=8192)
except Exception:
rel += SECTOR
continue
if res.eof and len(res.output) == 8192:
for base in (0, 4096):
entries = parse_dir_block(res.output[base : base + 4096])
if (
not entries
or len(entries) < 2
or entries[0][2] != b"."
or entries[1][2] != b".."
):
continue
inode = entries[0][0]
children = entries[2:]
if inode not in dirs or len(children) > len(dirs[inode]):
dirs[inode] = children
rel += align_sector(res.consumed + 4)
else:
rel += SECTOR
slot += SLOT
return dirs
def find_child(dirs, parent: int, name: bytes) -> int:
for inode, _file_type, child_name in dirs[parent]:
if child_name == name:
return inode
raise RuntimeError(f"missing child {name!r}")
def recover_message(records_path: str) -> str:
dirs = collect_directories(records_path)
home = find_child(dirs, 2, b"home")
user = find_child(dirs, home, b"kyouki")
current = find_child(dirs, user, b"f")
parts = ["f"]
seen = set()
while current not in seen:
seen.add(current)
children = dirs.get(current, [])
if not children:
break
if len(children) != 1:
raise RuntimeError(f"unexpected branch at inode {current}")
current, _file_type, name = children[0]
parts.append(name.decode("latin1"))
return "".join(parts)
def main() -> int:
message = recover_message(sys.argv[1])
match = re.search(r"HS\{[^}]+\}", message)
if not match:
raise RuntimeError(f"flag not found in {message!r}")
print(message)
print(match.group(0))
return 0
if __name__ == "__main__":
raise SystemExit(main())결과
solver를 실행하면 복원된 메시지와 flag가 함께 출력됩니다.
flag is HS{d0c0cb460720e6f21de1dccfeb34cab91d1c4990dbf3452f5a9a2095b9430fae}
HS{d0c0cb460720e6f21de1dccfeb34cab91d1c4990dbf3452f5a9a2095b9430fae}flag는 다음과 같습니다.
HS{d0c0cb460720e6f21de1dccfeb34cab91d1c4990dbf3452f5a9a2095b9430fae}Overview
prob 1 is a reversing challenge where you recover a flag from record data whose certain regions are overwritten with random bytes. The provided executable copies the original and then overwrites some regions with random data at a fixed period.
The conditions I confirmed are as follows.
- Category:
reversing - Goal: find and recover the structure containing the flag from the record data that was not overwritten with random bytes.
The first thing to check is the overwrite rule. You compute which positions were replaced with random bytes and which remained intact, then reconstruct the file system structure from the surviving data.
Challenge analysis
Terms used below:
ENC: the length overwritten with random bytes at once. The value is524288bytes.SKIP: the length left intact and skipped in each period. The value is9961472bytes.STRIDE: the overwrite period. Since it isSKIP + ENC, it is10485760bytes.frame: a raw LZO1X compressed stream contained within the record data.directory block: a4096-byte block containing ext4 directory entries.inode: a number that points to a file or directory in the ext4 file system.name: the name of a directory entry. In this challenge, single-character directory names make up the flag message.
Inspecting the Python bytecode of the PyInstaller-bundled executable shows the following flow.
ENC = 524288
SKIP = 9961472
STRIDE = SKIP + ENC
BUF = 1048576
def overwrite_pattern(f, offset, length):
f.seek(offset)
chunk = os.urandom(BUF)
remaining = length
while remaining > 0:
n = min(BUF, remaining)
f.write(chunk[:n])
remaining -= n
def make_output_name(path):
if not path.endswith(".records") and not path.endswith(".record_ids"):
raise ValueError("not a .records or .record_ids file: " + path)
return path + ".ENC"
def encrypt(path):
out = make_output_name(path)
shutil.copy2(path, out)
size = os.path.getsize(out)
with open(out, "r+b") as f:
offset = 0
while offset < size:
offset += SKIP
if offset >= size:
break
length = min(ENC, size - offset)
overwrite_pattern(f, offset, length)
offset += ENC
return outoverwrite_pattern writes data generated by os.urandom directly. So the original content of the overwritten regions cannot be recovered. Since the overwrite positions are fixed, though, the surviving regions can be picked out.
The positions overwritten with random bytes are computed by the following condition.
9961472 <= offset % 10485760 < 10485760So for every 10MiB, the first 9.5MiB survives and the last 512KiB is replaced with random bytes.
The regions of the record data that were not overwritten contain raw LZO1X streams. Streams that decompress correctly produced 8192 bytes of output, which splits into two 4096-byte ext4 blocks.
I parsed ext4 directory entries with the following structure.
inode : uint32
rec_len : uint16
name_len : uint8
file_type : uint8
name : name_len bytesTo accept something as a directory block, rec_len must not go out of the block bounds, and the first two entries must be . and ... Collecting the blocks that satisfy this condition lets you reconstruct the relationship between a directory inode and its child entries.
Among the recovered directories, the flow near the root is as follows.
root
home -> inode 16
home
kyouki -> inode 121
home/kyouki
.bash_logout
.profile
.bashrc
.ssh
.cache
f -> inode 2548
.sudo_as_admin_successful
.bash_historyHere f stands out. Following the recovered file system from home/kyouki/f, each directory has exactly one child directory, and all of their names are a single character.
Core idea
The solution collects the surviving compressed blocks and reconstructs part of the file system structure from them.
Analyzing the executable lets you compute exactly where the random overwrites are. Excluding these positions and attempting raw LZO1X streams, some streams yield ext4 directory blocks. Each directory block carries its own inode in its . entry, so you can build an inode -> children dictionary from it.
Then you follow the directory chain starting at home/kyouki/f in the recovered file system. The directory names themselves are the message. Continuing to follow directories that have a single child and concatenating the names produces a sentence of the form flag is ....
Solution process
Step 1. Computing the overwrite regions
The executable copies the target file, advances by SKIP, and overwrites up to ENC bytes with random data from that position. It then advances by ENC more and repeats the same operation.
The following function decides whether a given physical offset was overwritten with random bytes.
ENC = 524288
SKIP = 9961472
STRIDE = 10485760
def corrupt(offset):
rel = offset % STRIDE
return SKIP <= rel < SKIP + ENCThis gives the set of regions to skip during scanning. Decompression at overwritten positions can't be trusted even if some bytes happen to match, so the solver first excludes them with corrupt(offset).
Step 2. Recovering raw LZO1X streams
The record data appeared to hold compressed streams inside fixed-size slots. The scan starts at 0x4000-byte slots and takes candidate offsets at 0x200-byte intervals within them.
When attempting raw LZO1X decompression at a candidate position, I treated it as a valid frame if it satisfied the following conditions.
Decompression ends at the EOF marker.
The output length is 8192 bytes.An 8192-byte output can be split into two ext4 blocks. I treated each half as a directory block candidate and checked whether it matched the ext4 directory entry format.
Step 3. Collecting ext4 directory relationships
Parsing a directory block gives the current directory's inode from the . entry and the list of children from the remaining entries.
entries[0] = "." -> current directory inode
entries[1] = ".." -> parent directory inode
entries[2:] -> child entriesSince blocks for the same inode may be found multiple times, I kept the one whose child list was more fully recovered. This process yields relationships of the form dirs[inode] = children.
From these relationships I found home under the root directory and kyouki under it. After that, f among the children of home/kyouki looked like the starting point of the flag message.
Step 4. Tracing the single-character directory chain
Starting from f's inode and following child directories, each step has exactly one child. Concatenating the names produces the following chain.
f/l/a/g/ /i/s/ /H/S/{/d/0/c/0/c/b/4/6/0/7/2/0/e/6/f/2/1/d/e/1/d/c/c/f/e/b/3/4/c/a/b/9/1/d/1/c/4/9/9/0/d/b/f/3/4/5/2/f/5/a/9/a/2/0/9/5/b/9/4/3/0/f/a/e/}The slashes only mark the path separators. The actual message is each directory's name concatenated as-is.
flag is HS{d0c0cb460720e6f21de1dccfeb34cab91d1c4990dbf3452f5a9a2095b9430fae}Exploit / Solver
The final solver works in the following order.
- Skip the
offsets overwritten with random bytes. - Attempt raw LZO1X decompression at candidate
offsets. - Parse each
4096-byte half of the8192-byte output as an ext4 directory block. - Find the
inodeofhome/kyouki/f. - Follow the chain of directories with a single child, concatenating the names.
The main routines of the solver are below. decompress unpacks a raw LZO1X stream and returns output, consumed, and eof.
from __future__ import annotations
import re
import struct
import sys
from lzo1x_raw import decompress
SLOT = 0x4000
SECTOR = 0x200
SCAN_START = 0x6700000
SCAN_END = 0x6810000
ENC = 524288
SKIP = 9961472
STRIDE = 10485760
def corrupt(offset: int) -> bool:
rel = offset % STRIDE
return SKIP <= rel < SKIP + ENC
def align_sector(n: int) -> int:
return (n + SECTOR - 1) & ~(SECTOR - 1)
def valid_name(name: bytes) -> bool:
if not name or name in (b".", b".."):
return True
if b"\x00" in name or b"/" in name:
return False
return all(32 <= b < 127 for b in name)
def parse_dir_block(block: bytes):
entries = []
pos = 0
while pos + 8 <= len(block):
inode, rec_len, name_len, file_type = struct.unpack_from("<IHBB", block, pos)
if rec_len < 8 or rec_len % 4 or pos + rec_len > len(block):
return None
if inode:
if name_len > rec_len - 8 or file_type > 7:
return None
name = block[pos + 8 : pos + 8 + name_len]
if not valid_name(name):
return None
entries.append((inode, file_type, name))
else:
if not (rec_len == 12 and name_len == 0 and file_type in (0, 0xDE)):
return None
pos += rec_len
return entries if pos == len(block) else None
def collect_directories(records_path: str):
dirs = {}
with open(records_path, "rb") as f:
slot = SCAN_START - (SCAN_START % SLOT)
while slot < SCAN_END:
if corrupt(slot):
slot += SLOT
continue
f.seek(slot)
buf = f.read(SLOT + 0x3000)
rel = 4
while rel < SLOT and rel + 16 <= len(buf):
offset = slot + rel
if (
offset < SCAN_START
or offset >= SCAN_END
or corrupt(offset)
or buf[rel : rel + 16].count(0) == 16
):
rel += SECTOR
continue
try:
res = decompress(buf[rel:], max_out=8192)
except Exception:
rel += SECTOR
continue
if res.eof and len(res.output) == 8192:
for base in (0, 4096):
entries = parse_dir_block(res.output[base : base + 4096])
if (
not entries
or len(entries) < 2
or entries[0][2] != b"."
or entries[1][2] != b".."
):
continue
inode = entries[0][0]
children = entries[2:]
if inode not in dirs or len(children) > len(dirs[inode]):
dirs[inode] = children
rel += align_sector(res.consumed + 4)
else:
rel += SECTOR
slot += SLOT
return dirs
def find_child(dirs, parent: int, name: bytes) -> int:
for inode, _file_type, child_name in dirs[parent]:
if child_name == name:
return inode
raise RuntimeError(f"missing child {name!r}")
def recover_message(records_path: str) -> str:
dirs = collect_directories(records_path)
home = find_child(dirs, 2, b"home")
user = find_child(dirs, home, b"kyouki")
current = find_child(dirs, user, b"f")
parts = ["f"]
seen = set()
while current not in seen:
seen.add(current)
children = dirs.get(current, [])
if not children:
break
if len(children) != 1:
raise RuntimeError(f"unexpected branch at inode {current}")
current, _file_type, name = children[0]
parts.append(name.decode("latin1"))
return "".join(parts)
def main() -> int:
message = recover_message(sys.argv[1])
match = re.search(r"HS\{[^}]+\}", message)
if not match:
raise RuntimeError(f"flag not found in {message!r}")
print(message)
print(match.group(0))
return 0
if __name__ == "__main__":
raise SystemExit(main())Result
Running the solver prints the recovered message together with the flag.
flag is HS{d0c0cb460720e6f21de1dccfeb34cab91d1c4990dbf3452f5a9a2095b9430fae}
HS{d0c0cb460720e6f21de1dccfeb34cab91d1c4990dbf3452f5a9a2095b9430fae}The flag is as follows.
HS{d0c0cb460720e6f21de1dccfeb34cab91d1c4990dbf3452f5a9a2095b9430fae}