0xFUN CTF 2026 write up

2026. 2. 23. 15:10review 및 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등으로 입상해서 그런지  자격증도 줬다