수박 껍질만 핥고 안은 들여다보지도 못한 것 같은 이 찝찝함...
우선 지피티가 분석해주었던 flag.py 코드를 가져와보자.
import zlib as z # zlib 모듈을 z로 줄여 import (데이터 압축/해제용)
import base64 as b # base64 모듈을 b로 줄여 import (주의: 아래에서 동일 이름 b를 변수로도 사용함)
from PIL import Image as I # Pillow의 Image를 I로 줄여 import
# --- 원본 데이터 및 키 설정 ---
d = b'DH{Fake_Flags?}' # 숨기려는 원본 바이트(예: 플래그). b'...'는 바이트 리터럴
k = 0x55 # XOR에 사용할 1바이트 키
# --- 간단한 '암호화'와 인코딩 / 압축 ---
e = bytes([x ^ k for x in d]) # 각 바이트에 키를 XOR -> 결과를 bytes로 만듦
q = b.b64encode(e) # XOR 결과를 base64로 인코딩 (바이트 반환)
c = z.compress(q) # base64 결과를 zlib로 압축
# --- 100x100 RGB 이미지 생성 및 픽셀 접근자 얻기 ---
s = (100, 100) # 이미지 크기 (width, height)
im = I.new("RGB", s) # 새 RGB 이미지 생성 (기본 색은 검정 (0,0,0))
px = im.load() # 픽셀 읽기/쓰기용 매퍼
# --- 압축된 바이트를 비트열(string)로 변환 ---
bt = ''.join(f"{byte:08b}" for byte in c) # 각 바이트를 8비트 이진 문자열로 바꿔 이어붙임
i = 0 # 현재 읽고 있는 비트 인덱스(전체 비트 문자열에서의 위치)
# --- LSB에 비트들을 차례대로 심기 (R -> G -> B 순) ---
for y in range(s[1]): # 세로(높이) 루프
for x in range(s[0]): # 가로(너비) 루프
if i >= len(bt): # 모든 비트를 다 심었으면 내부 루프 탈출
break
r, g, b = px[x, y] # 현재 픽의 R, G, B 값 읽음
# 각 색 채널의 최하위 비트(LSB)를 bt에서 하나씩 꺼내어 덮어씀
r = (r & 0xFE) | int(bt[i]); i += 1
g = (g & 0xFE) | int(bt[i]) if i < len(bt) else g; i += 1
b = (b & 0xFE) | int(bt[i]) if i < len(bt) else b; i += 1
px[x, y] = (r, g, b) # 변경한 RGB를 픽셀에 다시 저장
if i >= len(bt): # 내부에서 빠져나온 뒤에도 끝났으면 외부 루프 탈출
break
im.save("flag.png") # 스테가노 이미지를 파일로 저장
# --- 원래의 PNG(testfile.png)와 새로 만든 flag.png를 읽어서 붙이는 과정 ---
with open("testfile.png", "rb") as f:
a = f.read() # 원본 PNG 바이트 전부 읽기
with open("flag.png", "rb") as f:
h = f.read() # 방금 만든 스테가노 PNG 바이트 전부 읽기
# PNG의 IEND 청크(파일 종료 청크)를 찾아 그 청크의 끝 위치(= IEND 타입 + CRC까지) 바로 뒤로 이동
j = a.find(b'IEND') + 12 # b'IEND'는 바이트 리터럴. +12는 length(4)+type(4)+CRC(4)를 건너뛰기 위함
# 원본 PNG의 IEND 끝(=j)까지 자른 것과 flag.png 전체를 이어써서 base.png로 저장
open("base.png", "wb").write(a[:j] + h)
import zlib as z # zlib 모듈을 z로 줄여 import (데이터 압축/해제용)
import base64 as b # base64 모듈을 b로 줄여 import (주의: 아래에서 동일 이름 b를 변수로도 사용함)
from PIL import Image as I # Pillow의 Image를 I로 줄여 import
base64는 이름만 들어도 알 수 있다고 해도, 나머지는 잘 모르는 기능이다. 지피티가 이미 주석처리로 다 써줬지만, 한번 더 짚고 넘어가면 zlib는 데이터를 압축하거나 압축 해제할때 쓰는 모듈이고, base64는 바이너리를 아스키 코드로 인/디코딩해주는 모듈이다. 마지막 줄은 파이썬에서 이미지를 다루는 라이브러리인 Pillow 의 Image 객체를 불러오는 줄이다.
zlib는 zlib.compress(b"hello") 로 압축, zlib.decompress(...) 로 복원을 할 수 있다.
base64는 b.b64encode(b"data") 로 인코딩, b.b64decode(...) 로 디코딩이 가능하다.
Image는 객체인 만큼 여러 함수가 있다.
im = I.new("RGB", (100,100)) # 100x100 RGB 이미지 새로 생성
px = im.load() # 픽셀 배열 접근
im.save("flag.png") # 이미지 파일 저장
# --- 원본 데이터 및 키 설정 ---
d = b'DH{Fake_Flags?}' # 숨기려는 원본 바이트(예: 플래그). b'...'는 바이트 리터럴
k = 0x55 # XOR에 사용할 1바이트 키
d라는 변수를 생성하고, 문자열 앞에 b를 붙임으로써 해당문자열을 바이트열(바이트 형태로 존재하게함.)로 만든다. 바이트열로 만드는 이유는 바이너리 연산을 바이트 단위로 하지 않으면 번거롭기 때문...
k라는 변수 안에는 0x55 (십진수로 85)가 저장되어 있다. 파이썬에서 0x 로 시작하면 int로 인식한다고 한다.
# --- 간단한 '암호화'와 인코딩 / 압축 ---
e = bytes([x ^ k for x in d]) # 각 바이트에 키를 XOR -> 결과를 bytes로 만듦
q = b.b64encode(e) # XOR 결과를 base64로 인코딩 (바이트 반환)
c = z.compress(q) # base64 결과를 zlib로 압축
e는 리스트 컴프리 헨션이라는 것을 사용 했다. 아까전 바이트열을 저장한 d 안의 각 바이트 x에 대해 XOR 연산 수행을 진행한다고 하는데, 쉽게 말하면 for문과 append를 사용해서 리스트를 만드는 코드를 한줄로 나타내는 것과 같다. 즉 d의 바이트 마다 k를 곱해서 암호화 시키는 코드다. (XOR 암호화)
q는 암호화시킨 e를 다시한번 base64로 인코딩 시키는 코드다. 왜 인코딩을 시키냐? e는 아직 바이너리 데이터고, 바이너리 데이터를 이미지에 집어넣는 것은 어렵기 때문에 안전하게 base64로(문자열로) 인코딩 해주는 것이다.
c는 인코딩해준 q를 압축시켜주는 코드다. 이미지의 LSB에 들어갈려면 크기가 작아야 하기 때문에 압축시켜준 것이다.
# --- 100x100 RGB 이미지 생성 및 픽셀 접근자 얻기 ---
s = (100, 100) # 이미지 크기 (width, height)
im = I.new("RGB", s) # 새 RGB 이미지 생성 (기본 색은 검정 (0,0,0))
px = im.load() # 픽셀 읽기/쓰기용 매퍼
s는 100x100 의 이미지를 만들때 사용할 튜플이다.
im의 new는 새 이미지를 생성할 때 사용하는 함수로, 기본형태는 new(mode, size) 다. mode에 RGB를 넣어줌으로써 색을 지정해주고(안지정해주면 검정색 됌), size는 아까 만든 튜플(s)를 사용해서 100x100으로 지정한다.
px는 im 의 이미지 픽셀조작 권한? 을 주는 것 같은 코드다. 정확히 이해는 못했지만 픽셀 조작 전 준비를 해주는 코드.
.load(): 이미지 안 픽셀을 조작할 수 있는 **픽셀 접근자(pixel accessor)**를 반환
# --- 압축된 바이트를 비트열(string)로 변환 ---
bt = ''.join(f"{byte:08b}" for byte in c) # 각 바이트를 8비트 이진 문자열로 바꿔 이어붙임
i = 0 # 현재 읽고 있는 비트 인덱스(전체 비트 문자열에서의 위치)
bt는 아까 압축까지 끝낸 c를 가지고 c의 바이트마다 8자리 이진 문자열로 변경해서 한줄의 긴 문자열로 이어붙여주는(.join() 사용) 코드다. 이제 이걸 이미지에 넣으면 된다.
i는 bt 비트 문자열에서 지금 어느 비트를 픽셀에 넣을지 가리키는 위치 표시기? 라고 한다...
# --- LSB에 비트들을 차례대로 심기 (R -> G -> B 순) ---
for y in range(s[1]): # 세로(높이) 루프
for x in range(s[0]): # 가로(너비) 루프
if i >= len(bt): # 모든 비트를 다 심었으면 내부 루프 탈출
break <<<여기까진 그냥 단순 for문
r, g, b = px[x, y] # 현재 픽의 R, G, B 값 읽음
# 각 색 채널의 최하위 비트(LSB)를 bt에서 하나씩 꺼내어 덮어씀
r = (r & 0xFE) | int(bt[i]); i += 1
g = (g & 0xFE) | int(bt[i]) if i < len(bt) else g; i += 1
b = (b & 0xFE) | int(bt[i]) if i < len(bt) else b; i += 1
px[x, y] = (r, g, b) # 변경한 RGB를 픽셀에 다시 저장
if i >= len(bt): # 내부에서 빠져나온 뒤에도 끝났으면 외부 루프 탈출
break
im.save("flag.png") # 스테가노 이미지를 파일로 저장
r, g, b = px[x, y] 는 해당 픽셀의 rgb값 정보를 가져오는 코드다. 편집모드를 킨다고 생각하면 쉽다. 이렇게 편집모드를 키고,
r = (r & 0xFE) | int(bt[i]); i += 1
g = (g & 0xFE) | int(bt[i]) if i < len(bt) else g; i += 1
b = (b & 0xFE) | int(bt[i]) if i < len(bt) else b; i += 1
이런 코드를 사용해서 bt는 각 LSB 덮어쓰기를 진행한다. 지피티가 설명을 잘해놓았길래 그대로 가져왔다.
1️⃣ (r & 0xFE)는 뭐야?
- 0xFE = 11111110 (이진수)
- r & 0xFE → R 값의 마지막 비트(LSB)를 0으로 만든 것
- 예:
- 즉, LSB를 깨끗하게 초기화 하는 과정
2️⃣ | int(bt[i])는 뭐야?
- bt[i] → 현재 삽입할 비트 ('0' 또는 '1', 문자열)
- int(bt[i]) → 정수 0 또는 1
- (r & 0xFE) | int(bt[i]) → R 값 마지막 비트에 bt[i]를 넣음
- 예:
- 이렇게 하면 원래 색은 거의 그대로, 마지막 비트만 bt[i]로 바뀜
3️⃣ i += 1
- 다음 비트를 쓸 수 있도록 인덱스를 1 증가
4️⃣ G, B 채널 처리
- if i < len(bt) → 마지막 비트까지 다 심었으면 건너뛰기
- G, B 채널도 R과 같은 방식으로 LSB를 교체
🔹 핵심 요약
- (채널 & 0xFE) → LSB 0으로 초기화
- | int(bt[i]) → bt[i] 비트로 LSB 덮어쓰기
- i += 1 → 다음 비트로 이동
- 원래 색은 거의 그대로, 마지막 비트만 데이터로 바꿈 → 스테가노그래피 핵심
이렇게 rgb 설정을 끝마치면 px[x, y] = (r, g, b) 로 값을 저장해준다. (편집모드 종료)
모든 루프가 다끝났으면 .save("이미지이름.png") 명령어를 사용해서 이미지를 저장해준다.
# --- 원래의 PNG(testfile.png)와 새로 만든 flag.png를 읽어서 붙이는 과정 ---
with open("testfile.png", "rb") as f:
a = f.read() # 원본 PNG 바이트 전부 읽기
with open("flag.png", "rb") as f:
h = f.read() # 방금 만든 스테가노 PNG 바이트 전부 읽기
이건 말그대로 원본파일인 testfile과 금방 만든 스테가노 파일인 flag를 a와 h라는 변수안에 바이트로 읽어준 것. (with은 파일을 열고 자동으로 닫아줌으로써 f.closed() 를 사용하지 않아도 된다.)
# PNG의 IEND 청크(파일 종료 청크)를 찾아 그 청크의 끝 위치(= IEND 타입 + CRC까지) 바로 뒤로 이동
j = a.find(b'IEND') + 12 # b'IEND'는 바이트 리터럴. +12는 length(4)+type(4)+CRC(4)를 건너뛰기 위함
# 원본 PNG의 IEND 끝(=j)까지 자른 것과 flag.png 전체를 이어써서 base.png로 저장
open("base.png", "wb").write(a[:j] + h)
j는 원본바이트인 a에서 IEND를 찾아 +12 해준만큼 이동한 위치. PNG 구조상 IEND 청크는 length(4)+type(4)+CRC(4) 의 구조를 가지고 있기 때문에 +12를 해준 것이다.
그리고 이런 j를 base.png라는 이미지를 생성에 쓰기모드로 불러온 다음 원본의 끝에서 +h (flag.png)를 해준 것이 두번째 코드다.
코드를 분석하다보니까, "그럼 원래있던 flag.png를 testfile로 이름 바꾸면 되지 않나?" 해서 바꾼 후에 flag.py를 실행시켜보니까...


어림도 없지. 복호화하는 코드 짜서 실행시켜 보니까 여전히 더미 플래그가 나옴... 그래도 XOR암호화 사용한 코드 분석하면서 많은 걸 알아간 것 같다.
'2025 - 2' 카테고리의 다른 글
| 3주차 기술 [ESRC 보안동향보고서] (0) | 2025.10.28 |
|---|---|
| 2 주차 모바일 포렌식 1장 (0) | 2025.09.30 |
| 2주차 워게임 [Hidden] 문제풀이 (1) | 2025.09.30 |
| 2주차 기술 [ESRC 보안동향보고서] (1) | 2025.09.30 |
| 1주차 워게임 [EZ-Des] 공부 (1) | 2025.09.23 |