2026. 2. 23. 15:10ㆍreview 및 write up
[Forensics] TLSB
You might know about Least Significant Bit (LSB) steganography, but have you ever heard of Third Least Significant Bit (TLSB) steganography?
설명대로 색상바이트에서 최하위 비트에 데이터를 숨기는 일반적인 LSB 스테가노그래피와 다르게 3번째 비트에 데이터가 숨겨져있는 문제였다

HxD로 열어보면 BMP 파일인걸 알 수 있다.

온라인 LSB 스테가노그래피 툴을 사용하면 bottom up 방식으로 pixel order를 할 수가 없어서 데이터는 뽑히는데 순서가 이상하게 나온다
워밍업 문제답게 나는 간단하게 GPT를 사용해서 추출했다
결과, 추출된 데이터는
Hope you had fun :). The Flag is: MHhmdW57VGg0dDVfbjB0X0wzNDV0X1MxZ24xZjFjNG50X2IxdF81dDNnfQ==
base64 디코딩을해보면 플래그를 확인할 수 있다

[Forensics] DTMF
A series of encoded messages awaits. Analyze the signals and uncover what’s hidden within.
signal을 분석해서 flag를 찾으라는 문제였고 message.wav라는 음성파일 하나가 제공됐다
DTMF가 뭔지 찾아보니 유선전화기에서 사용됐던 이중 톤 다중 주파수라고 한다
이걸 http://dialabc.com/sound/detect/index.html 라는 사이트를 통해 톤을 분석하니

0과1로만 이루어져 있는 모습을 볼 수있었다
이걸 8개씩 묶어서 Ascii 코드로 변환하면 MHJtZ2p7VHUxbTFfYjRoX2lzYzVfdm50cn0= 라는 base64 문자열이 나오게되는데
디코더로 돌려보니

플래그 형식이 맞지않아서 여기서 굉장히 많이 헤맸다. 중괄호가 나온걸 보면 방향성은 맞는 것같은데 뭘 놓친건지 몰랐는데

HxD로 열어보니 평문 문자열이 들어있었고 여러 시도 결과 비제네르 암호키로 이 평문을 넣으니 플래그 형식이 나왔다

[Froensics] Anormal Journey
Welcome to a world that’s been quietly lived in, and abruptly abandoned. The trails are faint, the chests are picked clean, and the obvious paths lead nowhere. If you want the next piece, don’t follow where the story starts… follow where the creator stopped. Some places remember the last footsteps better than any signpost.
제공된 파일은 RAR v5 아카이브 였고 마인크래프트 1.20.1 세이브 파일이 들어있었다
문제 설명이 마지막을 보라고 해서 마지막 위치에서 청크를 스캔해 모든 문자열을 추해 덤프파일을 만들었다.
import os
import re
import math
import struct
import gzip
import zlib
from typing import Any, Dict, Tuple, List, Iterable, Optional
# ----------------------------
# Minimal NBT parser (big-endian) sufficient for chunk NBT
# ----------------------------
TAG_END = 0
TAG_BYTE = 1
TAG_SHORT = 2
TAG_INT = 3
TAG_LONG = 4
TAG_FLOAT = 5
TAG_DOUBLE = 6
TAG_BYTE_ARRAY = 7
TAG_STRING = 8
TAG_LIST = 9
TAG_COMPOUND = 10
TAG_INT_ARRAY = 11
TAG_LONG_ARRAY = 12
def _read(fmt: str, buf: bytes, off: int) -> Tuple[Any, int]:
size = struct.calcsize(fmt)
return struct.unpack(fmt, buf[off:off + size])[0], off + size
def _read_string(buf: bytes, off: int) -> Tuple[str, int]:
ln, off = _read(">H", buf, off)
s = buf[off:off + ln].decode("utf-8", errors="replace")
return s, off + ln
def _parse_payload(tag_type: int, buf: bytes, off: int) -> Tuple[Any, int]:
if tag_type == TAG_BYTE:
v, off = _read(">b", buf, off)
return v, off
if tag_type == TAG_SHORT:
v, off = _read(">h", buf, off)
return v, off
if tag_type == TAG_INT:
v, off = _read(">i", buf, off)
return v, off
if tag_type == TAG_LONG:
v, off = _read(">q", buf, off)
return v, off
if tag_type == TAG_FLOAT:
v, off = _read(">f", buf, off)
return v, off
if tag_type == TAG_DOUBLE:
v, off = _read(">d", buf, off)
return v, off
if tag_type == TAG_BYTE_ARRAY:
n, off = _read(">i", buf, off)
return buf[off:off + n], off + n
if tag_type == TAG_STRING:
return _read_string(buf, off)
if tag_type == TAG_LIST:
child_type = buf[off]
off += 1
n, off = _read(">i", buf, off)
lst = []
for _ in range(n):
v, off = _parse_payload(child_type, buf, off)
lst.append(v)
return {"_list_type": child_type, "value": lst}, off
if tag_type == TAG_COMPOUND:
d: Dict[str, Any] = {}
while True:
t = buf[off]
off += 1
if t == TAG_END:
break
name, off = _read_string(buf, off)
v, off = _parse_payload(t, buf, off)
d[name] = v
return d, off
if tag_type == TAG_INT_ARRAY:
n, off = _read(">i", buf, off)
arr = []
for _ in range(n):
v, off = _read(">i", buf, off)
arr.append(v)
return arr, off
if tag_type == TAG_LONG_ARRAY:
n, off = _read(">i", buf, off)
arr = []
for _ in range(n):
v, off = _read(">q", buf, off)
arr.append(v)
return arr, off
raise ValueError(f"Unknown tag type: {tag_type}")
def parse_nbt(buf: bytes) -> Dict[str, Any]:
off = 0
root_type = buf[off]
off += 1
if root_type != TAG_COMPOUND:
raise ValueError(f"Root is not compound: {root_type}")
_root_name, off = _read_string(buf, off)
root, _ = _parse_payload(TAG_COMPOUND, buf, off)
return root
def iter_strings(obj: Any, path: str = "") -> Iterable[Tuple[str, str]]:
if isinstance(obj, dict):
if set(obj.keys()) == {"_list_type", "value"} and isinstance(obj.get("value"), list):
for i, v in enumerate(obj["value"]):
yield from iter_strings(v, f"{path}[{i}]")
else:
for k, v in obj.items():
yield from iter_strings(v, f"{path}/{k}")
elif isinstance(obj, list):
for i, v in enumerate(obj):
yield from iter_strings(v, f"{path}[{i}]")
elif isinstance(obj, (bytes, bytearray)):
return
elif isinstance(obj, str):
yield path, obj
# ----------------------------
# MCA (region) reading
# ----------------------------
def world_to_chunk(x: float, z: float) -> Tuple[int, int]:
return math.floor(x / 16), math.floor(z / 16)
def chunk_coords_to_region(chunk_x: int, chunk_z: int) -> Tuple[int, int]:
return math.floor(chunk_x / 32), math.floor(chunk_z / 32)
def read_mca_chunk_nbt(region_path: str, chunk_x: int, chunk_z: int) -> Optional[Dict[str, Any]]:
local_x = chunk_x & 31
local_z = chunk_z & 31
idx = local_x + local_z * 32
with open(region_path, "rb") as f:
header = f.read(8192)
if len(header) < 8192:
return None
loc = header[idx * 4:(idx + 1) * 4]
offset_sectors = (loc[0] << 16) | (loc[1] << 8) | loc[2]
sector_count = loc[3]
if offset_sectors == 0 or sector_count == 0:
return None
f.seek(offset_sectors * 4096)
length_bytes = f.read(4)
if len(length_bytes) < 4:
return None
length = struct.unpack(">I", length_bytes)[0]
ctype_b = f.read(1)
if len(ctype_b) < 1:
return None
ctype = ctype_b[0]
comp = f.read(length - 1)
if len(comp) != length - 1:
return None
if ctype == 1:
raw = gzip.decompress(comp)
elif ctype == 2:
raw = zlib.decompress(comp)
elif ctype == 3:
raw = comp
else:
return None
return parse_nbt(raw)
# ----------------------------
# Flag search
# ----------------------------
FLAG_RE = re.compile(r"0xfun\{[^}]+\}")
def find_flags_in_nbt(nbt_root: Dict[str, Any]) -> List[Tuple[str, str]]:
hits: List[Tuple[str, str]] = []
for p, s in iter_strings(nbt_root):
m = FLAG_RE.search(s)
if m:
hits.append((p, m.group(0)))
return hits
def dump_strings(nbt_root: Dict[str, Any], out_f) -> int:
c = 0
for p, s in iter_strings(nbt_root):
s2 = s.strip()
if not s2:
continue
out_f.write(f"{p}: {s2}\n")
c += 1
return c
def main(world_dir: str, radius: int, dump_all: bool) -> None:
# 이 월드에서 확인된 마지막 위치 (오버월드)
last_x = -948.3000000119209
last_z = 189.23750001192093
chunk_x, chunk_z = world_to_chunk(last_x, last_z)
rx, rz = chunk_coords_to_region(chunk_x, chunk_z)
region_path = os.path.join(world_dir, "region", f"r.{rx}.{rz}.mca")
print(f"[i] world_dir: {world_dir}")
print(f"[i] last pos approx: x={last_x}, z={last_z}")
print(f"[i] target chunk: ({chunk_x}, {chunk_z})")
print(f"[i] target region: r.{rx}.{rz}.mca")
print(f"[i] region path: {region_path}")
if not os.path.isfile(region_path):
raise FileNotFoundError(f"Region file not found: {region_path}")
dump_path = os.path.join(world_dir, "near_last_strings.txt")
dump_f = None
if dump_all:
dump_f = open(dump_path, "w", encoding="utf-8", errors="replace")
found = False
total_dumped = 0
for dz in range(-radius, radius + 1):
for dx in range(-radius, radius + 1):
cx = chunk_x + dx
cz = chunk_z + dz
nbt = read_mca_chunk_nbt(region_path, cx, cz)
if not nbt:
continue
hits = find_flags_in_nbt(nbt)
if hits:
found = True
print(f"\n[+] HIT in chunk ({cx}, {cz})")
for p, flag in hits:
print(f" - {p}: {flag}")
if dump_f:
dump_f.write(f"\n=== chunk ({cx},{cz}) ===\n")
total_dumped += dump_strings(nbt, dump_f)
if dump_f:
dump_f.close()
print(f"\n[i] wrote dump: {dump_path} (strings: {total_dumped})")
if not found:
print("\n[-] No 0xfun{...} flag found in scanned chunks.")
print(" - radius를 더 키워보세요 (예: 16, 24).")
print(" - 덤프 파일(near_last_strings.txt)에서 '0xfun', '{', 'fun'으로 검색하세요.")
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("usage: find_0xfun_flag.py <world_dir> [radius] [--dump]")
raise SystemExit(1)
world_dir = sys.argv[1]
radius = 8
dump_all = False
for a in sys.argv[2:]:
if a == "--dump":
dump_all = True
else:
try:
radius = int(a)
except ValueError:
pass
main(world_dir, radius, dump_all)
그 결과

책 페이지에 글이 엄청나게 적혀있는데 base64 문자열을 발견했다. 디코더를 돌려보면

참고로 이 때 당시에 해외에서는 67밈이 유행했었다. 마인크래프트 좌표로 676767 / -676767이 나온 것
이제 저 좌표 주위의 청크를 스캔해서 덤프파일을 생성했다
import os
import re
import math
import struct
import gzip
import zlib
from typing import Any, Dict, Tuple, List, Iterable, Optional
TAG_END=0; TAG_BYTE=1; TAG_SHORT=2; TAG_INT=3; TAG_LONG=4; TAG_FLOAT=5; TAG_DOUBLE=6
TAG_BYTE_ARRAY=7; TAG_STRING=8; TAG_LIST=9; TAG_COMPOUND=10; TAG_INT_ARRAY=11; TAG_LONG_ARRAY=12
def _read(fmt: str, buf: bytes, off: int):
size = struct.calcsize(fmt)
return struct.unpack(fmt, buf[off:off+size])[0], off+size
def _read_string(buf: bytes, off: int):
ln, off = _read(">H", buf, off)
return buf[off:off+ln].decode("utf-8", errors="replace"), off+ln
def _parse_payload(tag_type: int, buf: bytes, off: int):
if tag_type == TAG_BYTE: return _read(">b", buf, off)
if tag_type == TAG_SHORT: return _read(">h", buf, off)
if tag_type == TAG_INT: return _read(">i", buf, off)
if tag_type == TAG_LONG: return _read(">q", buf, off)
if tag_type == TAG_FLOAT: return _read(">f", buf, off)
if tag_type == TAG_DOUBLE: return _read(">d", buf, off)
if tag_type == TAG_BYTE_ARRAY:
n, off = _read(">i", buf, off); return buf[off:off+n], off+n
if tag_type == TAG_STRING:
return _read_string(buf, off)
if tag_type == TAG_LIST:
child_type = buf[off]; off += 1
n, off = _read(">i", buf, off)
lst = []
for _ in range(n):
v, off = _parse_payload(child_type, buf, off)
lst.append(v)
return {"_list_type": child_type, "value": lst}, off
if tag_type == TAG_COMPOUND:
d: Dict[str, Any] = {}
while True:
t = buf[off]; off += 1
if t == TAG_END: break
name, off = _read_string(buf, off)
v, off = _parse_payload(t, buf, off)
d[name] = v
return d, off
if tag_type == TAG_INT_ARRAY:
n, off = _read(">i", buf, off)
arr = []
for _ in range(n):
v, off = _read(">i", buf, off); arr.append(v)
return arr, off
if tag_type == TAG_LONG_ARRAY:
n, off = _read(">i", buf, off)
arr = []
for _ in range(n):
v, off = _read(">q", buf, off); arr.append(v)
return arr, off
raise ValueError(tag_type)
def parse_nbt(buf: bytes) -> Dict[str, Any]:
off = 0
root_type = buf[off]; off += 1
if root_type != TAG_COMPOUND:
raise ValueError(f"Root is not compound: {root_type}")
_, off = _read_string(buf, off)
root, _ = _parse_payload(TAG_COMPOUND, buf, off)
return root
def iter_strings(obj: Any, path: str="") -> Iterable[Tuple[str,str]]:
if isinstance(obj, dict):
if set(obj.keys())=={"_list_type","value"} and isinstance(obj.get("value"), list):
for i,v in enumerate(obj["value"]):
yield from iter_strings(v, f"{path}[{i}]")
else:
for k,v in obj.items():
yield from iter_strings(v, f"{path}/{k}")
elif isinstance(obj, list):
for i,v in enumerate(obj):
yield from iter_strings(v, f"{path}[{i}]")
elif isinstance(obj, str):
yield path, obj
def world_to_chunk(x: float, z: float) -> Tuple[int,int]:
return math.floor(x/16), math.floor(z/16)
def chunk_to_region(cx: int, cz: int) -> Tuple[int,int]:
return math.floor(cx/32), math.floor(cz/32)
def read_mca_chunk_nbt(region_path: str, cx: int, cz: int) -> Optional[Dict[str, Any]]:
lx = cx & 31
lz = cz & 31
idx = lx + lz*32
with open(region_path, "rb") as f:
header = f.read(8192)
loc = header[idx*4:(idx+1)*4]
off_sect = (loc[0]<<16) | (loc[1]<<8) | loc[2]
cnt = loc[3]
if off_sect == 0 or cnt == 0:
return None
f.seek(off_sect*4096)
length = struct.unpack(">I", f.read(4))[0]
ctype = f.read(1)[0]
comp = f.read(length-1)
if ctype == 1: raw = gzip.decompress(comp)
elif ctype == 2: raw = zlib.decompress(comp)
elif ctype == 3: raw = comp
else: return None
return parse_nbt(raw)
FLAG_RE = re.compile(r"0xfun\{[^}]+\}")
def main(world_dir: str, x: float, z: float, radius: int, dump: bool):
cx, cz = world_to_chunk(x, z)
rx, rz = chunk_to_region(cx, cz)
region_path = os.path.join(world_dir, "region", f"r.{rx}.{rz}.mca")
print(f"[i] coords x/z: {x} / {z}")
print(f"[i] chunk: ({cx}, {cz})")
print(f"[i] region: r.{rx}.{rz}.mca")
print(f"[i] path: {region_path}")
if not os.path.isfile(region_path):
raise FileNotFoundError(region_path)
dump_path = os.path.join(world_dir, "strings_at_coords.txt")
out = open(dump_path, "w", encoding="utf-8", errors="replace") if dump else None
found = False
for dz in range(-radius, radius+1):
for dx in range(-radius, radius+1):
tcx, tcz = cx+dx, cz+dz
nbt = read_mca_chunk_nbt(region_path, tcx, tcz)
if not nbt:
continue
hits = []
for p,s in iter_strings(nbt):
m = FLAG_RE.search(s)
if m:
hits.append((p, m.group(0)))
if hits:
found = True
print(f"\n[+] HIT chunk ({tcx},{tcz})")
for p,flag in hits:
print(f" - {p}: {flag}")
if out:
out.write(f"\n=== chunk ({tcx},{tcz}) ===\n")
for p,s in iter_strings(nbt):
s2 = s.strip()
if s2:
out.write(f"{p}: {s2}\n")
if out:
out.close()
print(f"\n[i] wrote dump: {dump_path}")
if not found:
print("\n[-] No 0xfun{...} found. radius를 키우거나 덤프에서 0xfun 검색하세요.")
if __name__ == "__main__":
import sys
if len(sys.argv) < 4:
print("usage: find_0xfun_at_coords.py <world_dir> <x> <z> [radius] [--dump]")
raise SystemExit(1)
world_dir = sys.argv[1]
x = float(sys.argv[2])
z = float(sys.argv[3])
radius = 8
dump = False
for a in sys.argv[4:]:
if a == "--dump":
dump = True
else:
try: radius = int(a)
except ValueError: pass
main(world_dir, x, z, radius, dump)

결과로 text를 검색해보니 한 링크가 나왔다

이걸 이용해서 home을 찾으라는 것 같은데 무슨 소린지 몰라서 정말 별 짓을 다했다
배드락 배치로 찾는법, 세이브파일 이용해서 홈찾기, 게임내의 모든 text 청크 스캔등 시간을 엄청썼는데
생각해보니 사진의 네더락이라는 빨간색 블록은 기본적으로 오버월드에 존재도 안하고 배드락이 있는 곳에 위치할리도 없다
그래서 청크 스캔을 오버월드의 모든 네더락 위치 좌표로 돌렸다
import os
import re
import math
import struct
import gzip
import zlib
from typing import Any, Dict, List, Optional, Tuple
# ----------------------------
# Minimal NBT parser (big-endian)
# ----------------------------
TAG_END=0; TAG_BYTE=1; TAG_SHORT=2; TAG_INT=3; TAG_LONG=4; TAG_FLOAT=5; TAG_DOUBLE=6
TAG_BYTE_ARRAY=7; TAG_STRING=8; TAG_LIST=9; TAG_COMPOUND=10; TAG_INT_ARRAY=11; TAG_LONG_ARRAY=12
def _read(fmt: str, buf: bytes, off: int):
size = struct.calcsize(fmt)
return struct.unpack(fmt, buf[off:off+size])[0], off+size
def _read_string(buf: bytes, off: int):
ln, off = _read(">H", buf, off)
return buf[off:off+ln].decode("utf-8", errors="replace"), off+ln
def _parse_payload(t: int, buf: bytes, off: int):
if t == TAG_BYTE: return _read(">b", buf, off)
if t == TAG_SHORT: return _read(">h", buf, off)
if t == TAG_INT: return _read(">i", buf, off)
if t == TAG_LONG: return _read(">q", buf, off)
if t == TAG_FLOAT: return _read(">f", buf, off)
if t == TAG_DOUBLE: return _read(">d", buf, off)
if t == TAG_BYTE_ARRAY:
n, off = _read(">i", buf, off)
return buf[off:off+n], off+n
if t == TAG_STRING:
return _read_string(buf, off)
if t == TAG_LIST:
child = buf[off]; off += 1
n, off = _read(">i", buf, off)
lst = []
for _ in range(n):
v, off = _parse_payload(child, buf, off)
lst.append(v)
return {"_list_type": child, "value": lst}, off
if t == TAG_COMPOUND:
d: Dict[str, Any] = {}
while True:
tt = buf[off]; off += 1
if tt == TAG_END:
break
name, off = _read_string(buf, off)
v, off = _parse_payload(tt, buf, off)
d[name] = v
return d, off
if t == TAG_INT_ARRAY:
n, off = _read(">i", buf, off)
arr = []
for _ in range(n):
v, off = _read(">i", buf, off)
arr.append(v)
return arr, off
if t == TAG_LONG_ARRAY:
n, off = _read(">i", buf, off)
arr = []
for _ in range(n):
v, off = _read(">q", buf, off)
arr.append(v)
return arr, off
raise ValueError(t)
def parse_nbt(buf: bytes) -> Dict[str, Any]:
off = 0
root_type = buf[off]; off += 1
if root_type != TAG_COMPOUND:
raise ValueError("root not compound")
_, off = _read_string(buf, off)
root, _ = _parse_payload(TAG_COMPOUND, buf, off)
return root
def unwrap_list(v: Any) -> List[Any]:
if isinstance(v, list):
return v
if isinstance(v, dict) and set(v.keys())=={"_list_type","value"} and isinstance(v.get("value"), list):
return v["value"]
return []
# ----------------------------
# MCA reading (region/*.mca)
# ----------------------------
def read_chunk_raw(region_path: str, cx: int, cz: int) -> Optional[bytes]:
lx = cx & 31
lz = cz & 31
idx = lx + lz * 32
with open(region_path, "rb") as f:
header = f.read(8192)
if len(header) < 8192:
return None
loc = header[idx*4:(idx+1)*4]
off_sect = (loc[0] << 16) | (loc[1] << 8) | loc[2]
cnt = loc[3]
if off_sect == 0 or cnt == 0:
return None
f.seek(off_sect * 4096)
length = struct.unpack(">I", f.read(4))[0]
ctype = f.read(1)[0]
comp = f.read(length - 1)
if len(comp) != length - 1:
return None
if ctype == 1:
return gzip.decompress(comp)
if ctype == 2:
return zlib.decompress(comp)
if ctype == 3:
return comp
return None
# ----------------------------
# block_states decoding (1.20.x)
# ----------------------------
def ceil_log2(n: int) -> int:
if n <= 1:
return 0
return (n - 1).bit_length()
def bits_per_block(palette_len: int) -> int:
if palette_len <= 1:
return 0
return max(4, ceil_log2(palette_len))
def get_index_from_longs(data: List[int], bit_index: int, bits: int) -> int:
long_i = bit_index >> 6
start = bit_index & 63
if long_i >= len(data):
return 0
v = (data[long_i] & ((1 << 64) - 1)) >> start
remaining = 64 - start
if remaining < bits and long_i + 1 < len(data):
v2 = data[long_i + 1] & ((1 << 64) - 1)
v |= v2 << remaining
return v & ((1 << bits) - 1)
def iter_target_in_chunk(chunk: Dict[str, Any], cx: int, cz: int, target: str,
y_min: Optional[int], y_max: Optional[int]) -> List[Tuple[int,int,int]]:
hits = []
sections = unwrap_list(chunk.get("sections"))
for sec in sections:
if not isinstance(sec, dict):
continue
y_sec = sec.get("Y")
if not isinstance(y_sec, int):
continue
base_y = y_sec * 16
if y_min is not None and base_y + 15 < y_min:
continue
if y_max is not None and base_y > y_max:
continue
bs = sec.get("block_states")
if not isinstance(bs, dict):
continue
palette = unwrap_list(bs.get("palette"))
if not palette:
continue
pal_names = []
for p in palette:
if isinstance(p, dict) and isinstance(p.get("Name"), str):
pal_names.append(p["Name"])
else:
pal_names.append("(unknown)")
if target not in pal_names:
continue
target_idx = pal_names.index(target)
bits = bits_per_block(len(pal_names))
data = bs.get("data")
data_longs = data if isinstance(data, list) else []
if bits == 0:
# 팔레트 1개짜리(그게 target인 경우)
for i in range(4096):
lx = i & 15
lz = (i >> 4) & 15
ly = (i >> 8) & 15
wy = base_y + ly
if y_min is not None and wy < y_min:
continue
if y_max is not None and wy > y_max:
continue
wx = cx * 16 + lx
wz = cz * 16 + lz
hits.append((wx, wy, wz))
continue
for i in range(4096):
idx = get_index_from_longs(data_longs, i * bits, bits)
if idx != target_idx:
continue
lx = i & 15
lz = (i >> 4) & 15
ly = (i >> 8) & 15
wy = base_y + ly
if y_min is not None and wy < y_min:
continue
if y_max is not None and wy > y_max:
continue
wx = cx * 16 + lx
wz = cz * 16 + lz
hits.append((wx, wy, wz))
return hits
# ----------------------------
# Scan overworld only
# ----------------------------
def main(world_dir: str, y_min: Optional[int], y_max: Optional[int]):
region_dir = os.path.join(world_dir, "region")
if not os.path.isdir(region_dir):
raise FileNotFoundError(region_dir)
target = "minecraft:netherrack"
out_path = os.path.join(world_dir, "netherrack_overworld.txt")
total_hits = 0
chunks_with_hits = 0
with open(out_path, "w", encoding="utf-8", errors="replace") as out:
out.write("dimension=overworld\n")
out.write(f"target={target}\n")
out.write(f"y_min={y_min} y_max={y_max}\n\n")
for fn in os.listdir(region_dir):
m = re.match(r"r\.(-?\d+)\.(-?\d+)\.mca$", fn)
if not m:
continue
rx = int(m.group(1)); rz = int(m.group(2))
region_path = os.path.join(region_dir, fn)
for lz in range(32):
for lx in range(32):
cx = rx * 32 + lx
cz = rz * 32 + lz
raw = read_chunk_raw(region_path, cx, cz)
if not raw:
continue
try:
chunk = parse_nbt(raw)
except Exception:
continue
hits = iter_target_in_chunk(chunk, cx, cz, target, y_min, y_max)
if not hits:
continue
chunks_with_hits += 1
total_hits += len(hits)
out.write(f"=== chunk ({cx},{cz}) region={fn} hits={len(hits)} ===\n")
for (x, y, z) in hits:
out.write(f"{x}\t{y}\t{z}\n")
out.write("\n")
out.write(f"SUMMARY chunks_with_hits={chunks_with_hits} total_hits={total_hits}\n")
print(f"[i] wrote: {out_path}")
print(f"[i] chunks_with_hits={chunks_with_hits} total_hits={total_hits}")
if __name__ == "__main__":
import sys
if len(sys.argv) not in (2,4):
print("usage: python find_netherrack_overworld.py <world_dir> [y_min y_max]")
print("example: python find_netherrack_overworld.py \"D:\\dd\\ANormalSticks Let's Play #1\" -64 320")
raise SystemExit(1)
world_dir = sys.argv[1]
y_min = None
y_max = None
if len(sys.argv) == 4:
y_min = int(sys.argv[2])
y_max = int(sys.argv[3])
main(world_dir, y_min, y_max)
마인크래프트 1.20.1에서 배드락은 y좌표 -60에서 생성되는데, 이 위치에 네더락이 있는 좌표는 딱 하나 나왔다

그래서 결국 세이브파일을 내 마인크래프트에 넣고 직접 접속해서 좌표를 가보니

상자가 줄세워져 있다. 이 상자를 열어보면

플래그가 이런식으로 상자마다 다이아 블록을 통해 적혀있는 모습을 확인할 수 있었다.
여담으로 가독성도 떨어지고 대소문자가 애매해서 티켓을 열고 문의하니 R이라고 써있던게 소문자 r 이였다
푼사람이 워낙에 적다보니 운영진이 언인텐인지 확인을 위해 어떻게 풀었는지도 물어봤는데 내 경우는 의도된 풀이라고한다.
최종 플래그: 0xfun{m3m0r135_hur7_s0mt1m3s}
마인크래프트 지식과 게임이 없다면 풀 수 없는 문제라서 misc에 가까운 문제였던 것같다..

최종 순위는 4등으로 입상해서 그런지 자격증도 줬다
'review 및 write up' 카테고리의 다른 글
| 0ctf perspective write up [rev/misc] (0) | 2025.12.31 |
|---|---|
| HeroCTF v7 [forensics] Operation Pensieve Breach - 1 write up (0) | 2025.12.11 |
| Platypwn 2025 write up (0) | 2025.12.04 |
| ACDC2025 write up (0) | 2025.11.07 |
| osu!CTF2025 [Forensics]map-dealer write up (0) | 2025.10.29 |