Step 240. 스테가노그래피: LSB, zsteg, 오디오 스펙트로그램 — 보이는 그림 뒤의 편지

Step 240. 스테가노그래피: LSB, zsteg, 오디오 스펙트로그램 — 보이는 그림 뒤의 편지

Level 3 — Forensics 트랙 | 난이도 ★★★☆☆ | 예상 소요 시간 4시간

전제: Step 239(매직 바이트, 치빙)를 마쳤다. 파이썬으로 파일을 바이트로 다룰 수 있다.

⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.

  • 준비물: 파이썬 3 + Pillow(실측: Python 3.12.14, Pillow 12.2.0).
  • 주의: zsteg, steghide, 오디오 스펙트로그램 도구는 이 환경에 없어 "출력 예시·화면 예시"로 소개합니다. LSB 은닉과 추출은 pillow로 처음부터 끝까지 직접 구현해 실측합니다.

암호가 "읽을 수 없게" 숨긴다면, 스테가노그래피(steganography)는 "있는 줄도 모르게" 숨깁니다. 휴일 풍경 사진 한 장이 사실은 편지 봉투일 수 있습니다. CTF 포렌식의 단골 종목이자, 실제 악성코드가 설정값이나 페이로드를 이미지 안에 심어 유통하는 데도 쓰이는 기술입니다. 오늘은 가장 고전적인 기법 — 픽셀의 최하위 비트(LSB)에 데이터를 심는 방법 — 를 직접 구현하고, 도구들(zsteg, steghide, 스펙트로그램)이 그 자리를 어떻게 찾는지 봅니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • LSB 은닉의 원리를 설명하고 파이썬으로 직접 숨기고 추출한다
  • 은닉 전후의 이미지를 픽셀 단위로 대조해 "눈에 안 보이는 이유"를 수치로 증명한다
  • 비트면(bit plane) 시각화로 숨겨진 데이터의 자리를 눈으로 확인한다
  • zsteg(무손실 PNG)와 steghide(JPEG)의 담당 영역을 구분한다
  • 오디오 스테가노그래피를 스펙트로그램으로 판독하는 절차를 안다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 + Pillow (실측: Python 3.12.14, Pillow 12.2.0)
오늘의 코드 픽셀 & 0xFE | 비트 (심기), 픽셀 & 1 (뽑기), 8비트씩 모아 바이트로
필요한 개념 최하위 비트(LSB), RGB 채널, 비트면, 손실·무손실 압축, 스펙트로그램
오늘의 산출물 메시지가 심긴 stego.png + 추출 스크립트 + LSB 비트면 이미지

2-1. 최하위 비트 — 256분의 1의 비밀

픽셀의 밝기는 0~255의 한 바이트입니다. 이 바이트의 맨 마지막 비트(LSB, Least Significant Bit)를 0에서 1로 바꿔도 밝기는 1/255만큼만 변합니다. 사람의 눈은 인접 픽셀의 1/255 차이를 구분하지 못합니다. 즉 모든 픽셀의 마지막 비트는 사실상 ‘빈 자리’입니다.

200×120짜리 작은 그림이라도 R 채널 하나에 24,000비트 = 3,000바이트를 심을 수 있습니다. 짧은 문서 하나가 들어가는 용량입니다.

2-2. 무손실과 손실 — 포맷이 은닉의 운명을 가른다

PNG는 무손실 압축입니다 — 저장했다 열어도 픽셀 값이 그대로입니다. 그래서 LSB 은닉이 살아남습니다. 반면 JPEG는 손실 압축입니다 — 저장 과정에서 픽셀 값 자체가 바뀌어 LSB가 갈려 나갑니다. 그래서 생태계가 갈립니다:

  • PNG/BMP → LSB 계열, 탐지 도구는 zsteg
  • JPEG → 손실 압축의 변환 계수(DCT)를 살짝 바꾸는 방식, 도구는 steghide
  • WAV → 오디오 샘플의 LSB, 또는 스펙트로그램에 그림 그리기

도구 선택이 포맷에서 출발하는 이유입니다.

2-3. zsteg — 채널 조합의 총조사 (출력 예시)

이 환경에는 없지만(gem install zsteg로 설치하는 루비 도구), 하는 일은 단순합니다: R/G/B/A 채널 × 비트 깊이(1~2비트) × 비트 순서(MSB/LSB 먼저)의 모든 조합으로 비트를 뽑아 보고, 글자처럼 보이는 결과를 보고합니다.

# 출력 예시 — zsteg 전체 채널 검사 (실제 실행하지 않았습니다)
$ zsteg stego.png
b1,r,lsb,xy         .. text: "DH{lsb_m4st3r_2026}"
b1,rgb,lsb,xy       .. text: "...."
b2,g,msb,xy         .. file: data

b1,r,lsb,xy는 "1비트, R 채널, 최하위 비트부터, 행 우선"이라는 뜻입니다. 오늘 우리가 심을 방식이 정확히 이 조합입니다.

2-4. 오디오 스펙트로그램 — 소리를 지도로 (화면 예시)

스펙트로그램은 가로축 시간, 세로축 주파수, 밝기가 세기인 "소리의 지도"입니다. 여기에 글자 모양으로 주파수 에너지를 배치하면, 들을 땐 잡음인데 보면 글자인 파일이 됩니다. CTF에서 wav 파일이 나오면 표준 절차는:

# 화면 예시 — Audacity에서의 판독 절차 (실제 실행하지 않았습니다)
1. Audacity로 suspicious.wav 열기
2. 트랙 이름 옆 ▼ → Spectrogram 으로 보기 전환
3. 5kHz~10kHz 근처에 글자/모스 부호가 그려져 있는지 확인

원리는 단순합니다 — 푸리에 변환이 시간축의 파형을 주파수 성분으로 분해해 주고, 출제자는 그 성분을 글자 모양으로 심어 둡니다.


3. 따라 하기

3-1. 실험용 커버 이미지 만들기

실제 사진 대신, 그라데이션으로 만든 200×120 그림을 씁니다 (규칙적인 패턴이라 은닉의 흔적이 더 잘 드러나는 교육용 재료입니다). 이 챕터의 로컬 출력은 2026-09-09 실측입니다.

입력 (lsb_lab.py)

from PIL import Image

W, H = 200, 120
img = Image.new("RGB", (W, H))
px = img.load()
for y in range(H):
    for x in range(W):
        px[x, y] = (x % 256, (x + y) % 256, (2 * y) % 256)
img.save("cover.png")

3-2. LSB에 메시지 심기

R 채널의 최하위 비트만 갈아 끼웁니다. 핵심 한 줄은 (r & 0xFE) | bit — 마지막 비트를 0으로 지운 뒤(& 0xFE) 심을 비트를 얹는(| bit) 것입니다.

SECRET = "DH{lsb_m4st3r_2026}"
payload = SECRET + "\x00"                      # 널 문자를 끝 표지로
bits = "".join(f"{b:08b}" for b in payload.encode())
print(f"메시지: {SECRET!r}  ({len(payload)}바이트 = {len(bits)}비트)")

stego = img.copy(); spx = stego.load()
i = 0
for y in range(H):
    for x in range(W):
        if i >= len(bits): break
        r, g, b = spx[x, y]
        spx[x, y] = ((r & 0xFE) | int(bits[i]), g, b)
        i += 1
    if i >= len(bits): break
stego.save("stego.png")
메시지: 'DH{lsb_m4st3r_2026}'  (20바이트 = 160비트)

읽는 법: 160비트면 첫 행 160픽셀의 R 값 끝자리에 메시지가 심겼습니다. 전체 24,000픽셀 중 극히 일부 — 이미지의 0.7% 자리만 건드린 것입니다.

3-3. "정말 안 보이나" — 픽셀 대조로 증명

은닉 전후를 수치로 비교합니다:

orig = Image.open("cover.png").load()
stg  = Image.open("stego.png").load()
diff = sum(1 for y in range(H) for x in range(W) if orig[x, y] != stg[x, y])
maxdelta = max(abs(orig[x, y][0] - stg[x, y][0]) for y in range(H) for x in range(W))
print(f"달라진 픽셀: {diff}개 / 전체 {W*H}개, 최대 밝기 변화: {maxdelta}/255")
달라진 픽셀: 69개 / 전체 24000개, 최대 밝기 변화: 1/255

(2026-09-09 실측.)

출력 읽는 법: 두 그림을 나란히 띄워 놓고 아무리 봐도 구분이 안 되는 이유가 이 한 줄에 있습니다. 달라진 픽셀은 69개(0.29%)뿐이고, 그마저 밝기 차이는 최대 1/255입니다. 그런데 그 69개의 끝자리에 160비트짜리 편지가 들어 있습니다. "변화 없음 = 정보 없음"이 아니라는 것 — 스테가노그래피의 존재 이유입니다.

3-4. 추출 — 끝자리만 모아서 읽기

심기의 역연산입니다. R의 & 1을 모아 8개씩 바이트로 접습니다:

got = []
for y in range(H):
    for x in range(W):
        got.append(str(stg[x, y][0] & 1))
out = bytearray()
for k in range(0, len(got) - 7, 8):
    byte = int("".join(got[k:k+8]), 2)
    if byte == 0:            # 끝 표지 도착
        break
    out.append(byte)
print("추출 결과:", out.decode())
추출 결과: DH{lsb_m4st3r_2026}

(2026-09-09 실측. 심은 문자열이 정확히 복원됐습니다.)

읽는 법: 이 스크립트가 zsteg의 b1,r,lsb,xy 칸이 하는 일 그대로입니다. zsteg는 이걸 모든 채널·비트 순서 조합으로 자동 반복할 뿐, 원리는 오늘 여러분이 쓴 이 코드입니다.

3-5. 비트면 시각화 — 숨은 자리를 눈으로 보기

각 픽셀의 R 최하위 비트만 꺼내 흑백(0→검정, 1→흰색)으로 그려 봅니다. 이것이 비트면(bit plane) 시각화입니다:

plane = Image.new("L", (W, H)); pp = plane.load()
for y in range(H):
    for x in range(W):
        pp[x, y] = 255 * (stg[x, y][0] & 1)
plane.save("lsb_plane.png")
# 원본 cover.png에도 같은 작업 → lsb_plane_orig.png

(2026-09-09 실측 — 두 이미지 파일 생성 확인.)

읽는 법: 우리의 커버 이미지는 x % 256이라는 규칙적인 그라데이션이라 원본의 LSB 비트면이 규칙적인 줄무늬로 나옵니다. 그런데 은닉본의 비트면 맨 윗줄(메시지 160비트가 심긴 자리)에는 줄무늬를 깨고 들어간 불규칙한 잡음 띠가 보입니다. 자연 사진에서는 이 구분이 훨씬 어렵지만, "비트면을 깔아 보면 숨겨진 자리가 흔적을 남긴다"는 감각은 동일합니다. stegsolve 같은 도구가 채널별·비트별로 이 화면을 넘겨 보게 해 주는 이유입니다.

3-6. 다른 포맷의 세계 — 출력 예시

JPEG는 LSB가 살아남지 않으므로 전용 도구가 다릅니다 (이 환경에는 없어 출력 예시입니다):

# 출력 예시 — steghide: JPEG의 변환 계수에 심긴 데이터 (실제 실행하지 않았습니다)
$ steghide info photo.jpg
"photo.jpg":
  format: jpeg
  capacity: 1.8 KB
  embedded file "flag.txt":
    size: 26.0 Byte
    encrypted: rijndael-128, cbc

$ steghide extract -sf photo.jpg    # 비밀번호 물으면 그냥 Enter (빈 비밀번호)
wrote extracted data to "flag.txt".

실전 절차의 골격: ① PNG면 zsteg 총조사, ② JPG면 steghide로 내장 여부 확인 후 빈 비밀번호 시도, ③ WAV면 스펙트로그램으로 전환해 글자·모스 부호 판독. 그리고 어느 포맷이든 안 나오면 비트 순서(MSB/LSB first)와 채널 순서(RGB vs BGR)를 바꿔 다시 뽑는 것 — 출제자의 단골 변형입니다.


4. 미션과 연습문제

미션 — 나만의 스테가노 문제 출제와 해독

  1. 3-2의 코드를 고쳐 메시지를 G 채널의 LSB에 심은 my_stego.png를 만든다
  2. 3-4의 추출기를 채널을 고를 수 있게(channel 인자 0=R, 1=G, 2=B) 일반화한다
  3. 자신의 파일로 시연한다: R 채널로 추출하면 깨진 문자열이, G 채널로 추출하면 메시지가 나와야 한다
  4. (도전) 2비트 은닉(& 0xFC | 두 비트)으로 바꿔 보고, 픽셀 대조 수치가 3-3과 어떻게 달라지는지 기록한다

연습문제

문제 1. LSB 은닉이 PNG에서는 살아남고 JPEG에서는 파괴되는 이유를 압축 방식의 차이로 설명하세요.

문제 2. 3-3에서 달라진 픽셀이 69개였는데, 심은 비트는 160개였습니다. 왜 160개가 아니라 69개일까요?

문제 3. 추출 결과가 ÔH{lsp...처럼 첫 글자만 다르게 나온다면, 어떤 변형(비트 순서·채널)을 의심하고 어떻게 확인하나요?

문제 4. 스펙트로그램 트릭에서, "귀로 들으면 잡음인데 눈으로 보면 글자"가 가능한 원리를 푸리에 변환의 관점에서 설명해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

채널 일반화 추출기의 핵심:

def extract(path, channel):
    im = Image.open(path).load()
    W, H = Image.open(path).size
    got = [str(im[x, y][channel] & 1) for y in range(H) for x in range(W)]
    out = bytearray()
    for k in range(0, len(got) - 7, 8):
        byte = int("".join(got[k:k+8]), 2)
        if byte == 0: break
        out.append(byte)
    return out

R(0)로 추출하면 커버 이미지의 무작위 LSB가 글자처럼 깨져 나오고, G(1)로 추출하면 메시지가 나옵니다 — 이 대조 자체가 "채널을 모르면 읽을 수 없다"는 은닉의 방어막 시연입니다. 4번 도전의 결과 예상: 최대 밝기 변화가 1/255에서 3/255로 커지고, 달라진 픽셀 수도 늘어납니다. 용량 2배의 대가로 은닉의 위장력이 떨어지는 트레이드오프입니다.

검증하는 법: ① my_stego.png에서 G 채널 추출이 메시지 원문과 일치하는가. ② R 채널 추출이 깨진 문자열인가(그래야 채널 선택이 의미 있음). ③ 2비트 은닉의 픽셀 대조 수치를 기록했는가.

연습문제 해답

문제 1 해답. PNG는 무손실 압축이라 저장·로드 과정에서 픽셀 값이 비트 단위로 보존되므로 심어 둔 LSB가 그대로 돌아옵니다. JPEG은 픽셀을 주파수 계수(DCT)로 바꾼 뒤 세세한 값을 반올림해 버리는 손실 압축이라, 재압축 과정에서 LSB가 바뀝니다. 그래서 JPEG 은닉은 픽셀이 아니라 변환 계수에 심는 별도 기법(steghide 계열)을 씁니다.

문제 2 해답. & 0xFE | bit는 원래 끝자리가 이미 심을 값과 같은 픽셀은 바꾸지 않기 때문입니다. 무작위라면 절반(80개)쯤이 바뀌고, 우리 실측(69개)도 그 근처입니다. 즉 "심은 비트 수"와 "달라진 픽셀 수"는 다릅니다 — 같은 값을 덮어쓴 픽셀은 변화로 잡히지 않습니다.

문제 3 해답. 첫 글자만 다른 패턴은 비트 순서(바이트 안에서 MSB first vs LSB first) 의심 1순위입니다. int("".join(bits[k:k+8]), 2) 대신 뒤집어서(bits[k:k+8][::-1]) 재조립해 봅니다. 그래도 안 되면 채널(R→G→B)을 바꿔 봅니다. zsteg가 하는 일이 정확히 이 순열의 총조사입니다.

문제 4 해답. 푸리에 변환은 파형을 주파수별 성분으로 분해합니다. 출제자가 특정 시각·특정 주파수에만 에너지를 몰아 넣으면, 시간-주파수 지도(스펙트로그램) 위에 그 점들이 글자 모양으로 배열됩니다. 귀는 전체 파형의 합만 듣기에 글자 배열을 인식하지 못하지만, 눈은 2차원 지도의 패턴을 바로 읽습니다. 듣기 위한 인코딩이 아니라 보기 위한 인코딩인 것입니다.

완료 기준 체크리스트

  • [ ] LSB 은닉의 원리(1/255, 무손실 포맷)를 설명할 수 있다
  • [ ] (r & 0xFE) | bit로 심고 & 1로 뽑는 코드를 직접 썼다
  • [ ] 은닉 전후 픽셀 대조로 위장력을 수치로 증명했다
  • [ ] 비트면 시각화로 은닉 자리를 확인했다
  • [ ] zsteg(PNG)와 steghide(JPEG)의 담당 구분을 안다
  • [ ] 추출 실패 시 비트 순서·채널 순서를 바꾸는 절차를 안다
  • [ ] 미션: 채널 선택 추출기와 G 채널 은닉 파일을 완성했다

6. 흔한 실수와 해결

벽 1. ModuleNotFoundError: No module named 'PIL'

증상: 스크립트 첫 줄에서 Pillow가 없다고 합니다.
원인: Pillow 미설치.
해결: pip install Pillow. 이 책의 관리 파이썬에는 이미 들어 있습니다 (실측: 12.2.0).

벽 2. 추출했는데 온통 깨진 문자다

증상: 글자처럼 보이는 구간이 아예 없습니다.
원인: 채널이나 비트 순서가 출제자의 것과 다릅니다. R에 심었는데 G에서 뽑는 식입니다.
해결: 채널(0,1,2) × 비트 순서(정방향/역방향) 여섯 조합을 전부 돌려 보세요. 미션의 채널 선택 추출기가 바로 그 도구입니다. 그래도 안 나오면 2비트 은닉이나 픽셀 건너뛰기 같은 변형입니다.

벽 3. PNG로 저장했는데 메시지가 사라졌다

증상: 심고 저장했다가 다시 열어 추출하니 안 나옵니다.
원인: JPEG로 저장했거나(stego.jpg로 저장하면 손실 압축이 LSB를 갈아 버립니다), 이미지를 다른 프로그램에서 한 번 리사이즈·재저장한 경우입니다.
해결: 반드시 무손실 포맷(PNG/BMP)으로, 원본 크기 그대로 저장하세요. "한 번이라도 손실 압축을 거치면 LSB는 죽는다"가 철칙입니다.

벽 4. 널 종결자 없이 추출했더니 쓰레기가 무한히 나온다

증상: 메시지 뒤로 이상한 글자가 수백 개 붙습니다.
원인: 끝 표지(\x00)를 안 심어 두면 추출기가 커버 이미지의 원래 LSB까지 전부 글자로 해석합니다.
해결: 심을 때 payload + "\x00" 습관을 들이거나, 메시지 길이를 앞 4바이트에 심는 방식을 쓰세요. 실전 문제는 둘 다 없을 수 있으니, "글자가 이어지다가 뚝 끊기는 지점"을 눈으로 찾는 것도 기술입니다.

벽 5. zsteg가 JPG를 거부한다

증상 (출력 예시): zsteg photo.jpg → 이 도구는 PNG/BMP만 지원한다는 오류.
원인: 포맷별 생태계 구분(2-2)을 놓친 것입니다. JPEG은 LSB가 살지 않아 zsteg의 대상이 아닙니다.
해결: JPG는 steghide 계열로. 파일 포맷 확인부터가 스테가노그래피 분석의 제1보입니다 — 어제 배운 file이 여기서도 첫 수입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
LSB 은닉 픽셀 끝자리 1비트는 눈에 안 보이는 빈 자리 — 여기에 심는다
무손실 vs 손실 PNG는 LSB가 살고, JPEG은 죽는다 — 포맷이 기법을 가른다
비트면 시각화 특정 비트만 흑백으로 — 숨은 자리의 흔적을 눈으로 보는 기법
채널·비트 순서 순열 추출 실패 시의 총조사 목록 — zsteg가 자동화하는 것
스펙트로그램 소리의 시간-주파수 지도 — 들리지 않는 것을 보게 하는 렌즈

오늘의 코드·명령어

코드·명령 하는 일
(r & 0xFE) | bit R 채널 LSB에 비트 심기
pixel & 1 LSB 꺼내기
int(bits, 2) × 8 비트 8개를 바이트로 재조립
zsteg 파일 (출력 예시) PNG/BMP 전 채널 LSB 총조사
steghide info/extract -sf 파일 (출력 예시) JPEG 내장 데이터 확인·추출
Audacity → Spectrogram (화면 예시) 오디오의 그림 글자 판독

명령어보다 중요한 감각

스테가노그래피의 핵심 질문은 "뭐가 숨겨져 있나"가 아니라 "이 파일의 어느 자리가 검사를 피해 가는가"입니다. LSB, 메타데이터 칸, IEND 뒤, 스펙트로그램의 고주파 — 전부 "아무도 보지 않는 자리"입니다. 반대로 분석자의 기술은 그 자리들의 지도를 머릿속에 갖고, 순서대로 들여다보는 것입니다.

그리고 오늘 직접 심어 봤기에 알게 된 진실 하나 — 은닉은 완벽하지 않습니다. 비트면에 띠가 남고, 통계가 어긋나고, 채널을 바꾸면 드러납니다. 숨기는 쪽과 찾는 쪽의 기술이 같은 원리를 두고 다투는 게임이라는 것, 그것이 포렌식 트랙 내내 반복될 구도입니다.


전부 체크되면 Step 240 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.