Step 90. 프로젝트 — XOR 파일 암호화/복호화 도구
Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★☆☆ | 예상 소요 시간 3시간
전제: Step 41~46의 파이썬 기초(리스트, 파일 입출력), Step 54의 비트 연산, Step 64의 명령행 인자를 안다.
- 준비물: 파이썬, 텍스트 에디터, 아무 파일이나 하나(암호화 실험용 — 중요한 파일은 절대 아닌 것으로).
- 주의: 오늘 만드는 암호는 교육용입니다. 실전에서 쓸 수 없다는 사실까지 스스로 증명하는 것이 오늘의 핵심 목표입니다. 오늘의 "깨기" 실험은 전부 여러분이 직접 암호화한 파일에만 적용합니다.
암호라고 하면 첩보 영화의 검은 상자를 떠올리지만, 그 심장에는 놀랄 만큼 단순한 연산이 있습니다. 오늘의 주인공 XOR(배타적 논리합, 기호 ⊕) — 규칙은 단 한 줄, "같으면 0, 다르면 1." 이 연산에는 마법 같은 성질이 있습니다: 어떤 값 A를 키 K로 XOR한 뒤 다시 K로 XOR하면 원래의 A로 돌아옵니다. 이 성질 하나로 "키를 아는 사람만 되돌릴 수 있는 변환"이 만들어집니다. 오늘의 목표는 두 가지입니다 — XOR로 파일을 암호화하고 복호화하는 도구를 직접 만들고, 이 암호가 왜 약한지 스스로 깨부수는 것. 만들고, 부수고, 이유를 설명하는 세 박자가 보안 공부의 가장 전형적인 리듬입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- XOR의 복원 성질(A ⊕ K ⊕ K = A)을 파이썬
^연산으로 보여 준다 "rb"/"wb"바이너리 모드로 파일을 읽고 쓴다- 명령행 인자를 받는 enc/dec CLI 도구를 완성한다
- 256번 전수 시도(브루트포스)로 한 바이트 키 암호를 깬다
- 키 재사용이 왜 위험한지 실험으로 증명하고, 이 암호의 한계를 설명한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 (스크립트 + 한 줄 실행) |
| 오늘의 도구 | ^(XOR 연산자), bytes(), open(..., "rb"/"wb"), sys.argv, range(256) |
| 필요한 개념 | XOR 진리표, 대칭키, 바이너리 파일, 키 공간, 알려진 평문 공격 |
| 오늘의 산출물 | xor_tool.py(암복호화 도구), crack.py(전수 시도 크래커) |
2-1. XOR의 마법 성질 — 스위치를 두 번 누르기
XOR의 진리표는 이렇습니다.
| A | B | A ⊕ B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
여기서 성질을 하나만 외우면 됩니다. A ⊕ K ⊕ K = A. 전등 스위치를 같은 방식으로 두 번 누르면 원상태인 것과 같습니다. 파이썬에서 XOR은 ^ 기호입니다.
2-2. 대칭키 — 같은 열쇠로 잠그고 여는 암호
오늘의 방식처럼 암호화할 때와 복호화할 때 같은 키를 쓰는 암호를 대칭키 암호라고 부릅니다. 현관 열쇠와 같습니다 — 잠글 때 쓴 열쇠로 열 때도 엽니다. 반대 개념은 공개키 암호(잠그는 열쇠와 여는 열쇠가 다름)로, 나중 단계에서 만납니다.
2-3. 바이너리 파일 — 글자가 아니라 바이트
지금까지 open("파일")로 읽은 것은 텍스트였습니다. 하지만 사진, 실행 파일, 압축 파일에는 "글자로 해석할 수 없는 바이트"가 있습니다. 이런 파일을 다룰 때는 모드에 b를 붙입니다 — "rb"(바이너리 읽기), "wb"(바이너리 쓰기).
바이너리로 읽으면 파이썬은 파일을 바이트의 나열(bytes 객체)로 줍니다. 각 바이트는 0~255의 숫자입니다. 그리고 XOR은 숫자끼리 하는 연산이죠 — 모든 파일은 숫자 나열이고, 숫자는 XOR할 수 있다. 이것이 "어떤 파일이든 암호화할 수 있다"는 말의 실체입니다.
2-4. 스트림 암호의 뿌리와 키 공간
키를 데이터만큼 길게 늘여 한 바이트씩 XOR하는 암호를 스트림 암호라고 합니다. 오늘의 도구는 키 한 바이트를 반복 사용하는, 가장 원시적인 스트림 암호입니다. 실전의 스트림 암호(예: ChaCha20)도 뼈대는 같습니다 — 진짜 무작위 같은 키 스트림을 만들어 XOR. 차이는 "키 스트림을 얼마나 예측 불가능하게 만드는가"뿐입니다.
그리고 오늘의 또 다른 주인공이 키 공간(가능한 키의 개수)입니다. 한 바이트 키의 키 공간은 256개. 실전 암호 AES-128은 2의 128제곱 개입니다. 이 차이가 3-7에서 "1초 만에 깨지는가, 우주의 나이보다 오래 걸리는가"를 가릅니다.
3. 따라 하기
3-1. XOR 성질 확인 — 마법의 첫 목격
입력:
python -c "print(65 ^ 42); print((65 ^ 42) ^ 42)"
출력 (2026-09-09 실측):
107
65
읽는 법: 65(문자 ‘A’)를 42로 뒤집으면 107(‘k’)이 되고, 같은 42로 다시 뒤집으니 65로 돌아왔습니다. 107이라는 중간값만으로는 키 42 없이 원래가 뭔지 알 수 없습니다.
왜 하는가: 오늘 만드는 모든 것이 이 두 줄 위에 서 있습니다. 직접 눈으로 확인한 성질만이 믿을 수 있는 도구가 됩니다.
3-2. 바이너리로 파일 읽기
입력: 실험용 파일을 만들고 읽어 봅니다.
echo "hello secret" > test.txt
python -c "data = open('test.txt','rb').read(); print(data); print(list(data)[:5])"
출력 (2026-09-09 실측):
b'hello secret\n'
[104, 101, 108, 108, 111]
읽는 법: b'...'는 바이트 나열이라는 표시입니다. 뒤의 리스트가 각 바이트의 숫자값입니다 — h는 104, e는 101. 파일이 숫자들이라는 말이 증명되었습니다. 암호화는 "이 숫자들을 전부 XOR로 뒤집는 것"이 될 것입니다.
3-3. 암호화 함수 — 한 줄의 핵심
입력: xor_tool.py를 만들기 시작합니다.
def xor_bytes(data, key):
return bytes([b ^ key for b in data])
읽는 법: 리스트의 각 바이트 b를 key와 XOR해 새 바이트 나열을 만듭니다. bytes([...])는 숫자 리스트를 바이트로 포장합니다. 이 한 줄이 암호화이자 복호화입니다 — XOR은 대칭이라 함수가 하나면 됩니다. 복잡해 보이는 "암호화 프로그램"의 심장이 사실 이 한 줄이고, 나머지 코드는 전부 파일 읽기·쓰기와 인자 처리라는 포장지입니다.
3-4. CLI로 완성하고 왕복하기
입력: xor_tool.py 전체를 완성합니다.
import sys
def xor_bytes(data, key):
return bytes([b ^ key for b in data])
def main():
if len(sys.argv) != 4:
print("사용법: python xor_tool.py enc|dec 입력파일 키(0~255)")
return
mode, path, key = sys.argv[1], sys.argv[2], int(sys.argv[3])
out = path + ('.enc' if mode == 'enc' else '.dec')
data = open(path, 'rb').read()
open(out, 'wb').write(xor_bytes(data, key))
print(f"{mode} 완료: {out}")
main()
입력 (실행):
python xor_tool.py enc test.txt 42
python xor_tool.py dec test.txt.enc 42
cat test.txt.enc.dec
cmp test.txt test.txt.enc.dec
출력 (2026-09-09 실측):
enc 완료: test.txt.enc
dec 완료: test.txt.enc.dec
hello secret
cmp는 아무 출력 없이 끝났습니다 — 두 파일이 바이트 단위로 완전히 같다는 뜻입니다 (Windows에는 fc /b가 있습니다).
읽는 법: sys.argv는 명령행에 적은 단어들의 리스트입니다(Step 64). 모드가 enc든 dec든 하는 일은 같습니다 — XOR은 대칭이니까요. 암호 도구의 완료 조건은 "되돌아오는가"이고, 왕복이 증명되었습니다.
흥미로운 발견 (2026-09-09 실측): 암호문
test.txt.enc를od -c로 들여다보면B O F F E ...처럼 우연히 읽히는 글자들이 나옵니다. ‘h'(104)와 키 42의 XOR이 ‘B’이기 때문입니다. 암호문이 "이상한 기호"일 필요는 없다는 것 — 암호문은 그저 "키 없이는 의미를 알 수 없는 바이트"입니다.
3-5. 예측해 보기 — 키가 틀리면 어떻게 될까
암호화할 때 키 42를 쓰고, 복호화할 때 키 43을 쓰면 결과는?
- (a) 대충 비슷하게 복원된다
- (b) 완전히 엉뚱한 바이트가 나온다
- (c) 오류가 발생한다
직접 확인:
python -c "data=open('test.txt.enc','rb').read(); print(bytes([b ^ 43 for b in data]))"
출력 (2026-09-09 실측):
b'idmmn!rdbsdu\x0b'
정답에 가장 가까운 것은 (b) 입니다 — 복원은 실패했고 오류도 없습니다. 다만 실측이 보여 주는 재미있는 디테일이 있습니다: 키가 1만 어긋나면(42 대신 43) 각 바이트가 정확히 1만큼 어긋납니다 — "hello"가 "idmmn"처럼요. 42와 43의 XOR이 1이기 때문입니다. 키가 더 멀리 어긋나면 결과는 완전히 쓰레기가 됩니다. XOR에는 "비슷하게 맞다"가 없고, 틀린 키는 조용히 틀린 답을 줍니다.
3-6. 깨부수기 — 256번의 전수 시도
내 도구를 내가 공격해 봅니다. 키를 모른다고 가정합시다.
입력: crack.py:
data = open('test.txt.enc', 'rb').read()
for key in range(256):
plain = bytes([b ^ key for b in data])
if b'hello' in plain:
print(f"키 후보: {key} → {plain[:20]}")
출력 (2026-09-09 실측):
키 후보: 42 → b'hello secret\n'
읽는 법: 키 후보 256개를 전부 돌려 "읽히는 결과"를 찾았습니다. 1초도 안 걸립니다. 이것이 오늘 도구가 "교육용"인 이유의 증명입니다 — 키 공간이 256개인 암호는 컴퓨터 앞에서 무방비입니다.
왜 하는가: "키가 길어야 한다"는 교과서 문장의 이유를 방금 손으로 확인했습니다. AES-128의 키 공간(2의 128제곱)이 왜 필요한지 더 설명이 필요 없을 것입니다.
3-7. 키 재사용의 위험 — 같은 키로 두 번 암호화하면
XOR 암호에는 전수 시도 말고도 더 교묘한 약점이 있습니다. 같은 키로 두 메시지를 암호화하면, 두 암호문을 XOR하는 순간 키가 상쇄됩니다: C1 ⊕ C2 = (M1 ⊕ K) ⊕ (M2 ⊕ K) = M1 ⊕ M2.
입력:
key = 77
m1 = b'attack at dawn'
m2 = b'retreat at noon'
c1 = bytes([b ^ key for b in m1])
c2 = bytes([b ^ key for b in m2])
print('c1 XOR c2 == m1 XOR m2:', bytes(a ^ b for a, b in zip(c1, c2)) == bytes(a ^ b for a, b in zip(m1, m2)))
print('복구된 키:', c1[0] ^ ord('a')) # 첫 글자가 'a'임을 안다면
출력 (2026-09-09 실측):
c1 XOR c2 == m1 XOR m2: True
복구된 키: 77
읽는 법: 키를 전혀 모르는데도 두 암호문의 XOR이 두 평문의 XOR과 정확히 일치했습니다 — 키가 지워진 것입니다. 게다가 평문의 한 글자라도 알면(예: 메시지가 "attack"으로 시작한다는 추정) 키가 그대로 복구됩니다. 이것을 알려진 평문 공격(known-plaintext attack)이라고 합니다.
왜 중요한가: "키는 한 번만, 한 메시지에만"이 스트림 암호의 철칙인 이유입니다. 이 원리가 바로 다음에 나올 OTP(일회용 암호표)의 조건이기도 합니다.
3-8. 눈으로 보는 암호화 — 바이너리 파일 왕복
텍스트 말고 이진 파일로 실험하면 범용성이 눈에 보입니다. 실험용 이미지(PPM 형식)를 파이썬으로 직접 만들어 봅시다.
입력:
python -c "
w,h=64,64
with open('photo.ppm','wb') as f:
f.write(b'P6\n64 64\n255\n')
for y in range(h):
for x in range(w):
f.write(bytes([x*4 % 256, y*4 % 256, 128]))
print('photo.ppm 생성')"
python xor_tool.py enc photo.ppm 200
python xor_tool.py dec photo.ppm.enc 200
cmp photo.ppm photo.ppm.enc.dec
출력 (2026-09-09 실측):
photo.ppm 생성
enc 완료: photo.ppm.enc
dec 완료: photo.ppm.enc.dec
cmp가 다시 침묵했습니다 — 이미지 파일도 바이트 단위로 완벽히 왕복했습니다. 암호화된 photo.ppm.enc는 헤더(P6)까지 뒤집혔기 때문에(실측: 앞 바이트들이 230 376 302 ...로 변함) 어떤 이미지 뷰어도 열지 못합니다.
읽는 법: 텍스트든 그림이든 파일은 바이트 나열이고, 바이트는 XOR됩니다. "암호화하면 프로그램도 못 알아본다"는 것이 어떤 상태인지 체감하는 실험입니다.
4. 미션과 연습문제
미션 — 완성형 도구와 자기 분석
xor_tool.py가 enc/dec 모두 동작하고, 왕복 결과가 원문과 일치하는지cmp(또는fc /b)로 증명합니다- 텍스트가 아닌 파일(이미지, 압축 파일 등)에도 왕복 성공시킵니다
crack.py로 자기 암호문을 깨서 키를 찾아냅니다- Step 89의 위키에
XOR암호.md를 씁니다 — 한 줄 요약 / 사용 명령 / 막혔던 점 / "왜 약한가" 3줄 설명 - 보강 도전(선택): 한 바이트 대신 여러 글자짜리 키를 받아 바이트마다 돌아가며 XOR하게 고치고, 그래도 여전히 약한 이유를 정리합니다
연습문제
문제 1. A ⊕ K ⊕ K = A가 성립하는 이유를 진리표의 어느 줄에서 읽을 수 있는지 설명해 보세요.
문제 2. 암호화·복호화에 같은 함수 하나면 충분한 이유를 "대칭키"라는 단어와 함께 설명해 보세요.
문제 3. 한 바이트 키 XOR 암호가 1초 만에 깨지는 이유와, AES-128이 전수 시도로 깨지지 않는 이유를 "키 공간"으로 비교해 보세요.
문제 4. 같은 키로 암호화한 두 암호문을 XOR하면 무슨 일이 일어나며, 이것이 왜 위험한지 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
도구는 3-4의 코드가 완성형입니다. 미션의 검증 명령만 모으면:
python xor_tool.py enc 원본파일 42
python xor_tool.py dec 원본파일.enc 42
cmp 원본파일 원본파일.enc.dec # 무출력이면 왕복 성공
python crack.py # 키 후보: 42 가 떠야 함
선택 과제(여러 글자 키)의 뼈대:
def xor_bytes(data, key):
return bytes([b ^ key[i % len(key)] for i, b in enumerate(data)])
i % len(key)로 키의 바이트를 돌아가며 씁니다. 그래도 약한 이유: 키 공간이 커지기는 하지만, 키가 반복되는 한 같은 키 바이트가 주기적으로 다시 쓰이고(3-7의 키 재사용 문제가 주기마다 반복), 키 길이를 추정해 나누어 전수 시도하는 공격이 가능합니다.
검증하는 법: ① cmp가 침묵하는가. ② crack.py가 올바른 키를 찾는가. ③ 위키 문서에 "왜 약한가"가 적혔는가. 전부 ‘예’이면 완성입니다.
연습문제 해답
문제 1 해답. 진리표에서 K=0이면 A ⊕ 0 = A(1·3행: 그대로), K=1이면 A ⊕ 1 = A의 반전(2·4행)입니다. 즉 XOR은 "K=1인 자리만 뒤집는" 연산이라, 같은 K로 두 번 뒤집으면 뒤집힌 자리가 다시 뒤집혀 원래대로 돌아옵니다.
문제 2 해답. XOR은 같은 키로 두 번 적용하면 원래대로 돌아오는 대칭 연산이므로, 암호화와 복호화가 같은 계산입니다. 잠글 때와 열 때 같은 열쇠를 쓰는 대칭키 암호이기 때문에 함수 하나로 양방향을 커버합니다.
문제 3 해답. 한 바이트 키는 후보가 256개뿐이라 0~255를 전부 시도해 "읽히는 것"을 고르면 끝나기 때문입니다 (3-6 실측: 1초 미만). AES-128은 후보가 2의 128제곱 개라, 초당 수조 개를 시도해도 우주의 나이보다 오래 걸립니다. 암호의 강함은 키 공간의 크기에서 나옵니다.
문제 4 해답. 키가 상쇄되어 두 평문의 XOR(M1 ⊕ M2)이 그대로 드러납니다 (3-7 실측: True). 두 메시지의 차이 패턴이 노출되고, 한쪽 내용의 일부라도 알면 다른 쪽과 키까지 복구됩니다 — 그래서 스트림 암호에서 키 재사용은 절대 금지입니다.
완료 기준 체크리스트
- [ ]
^연산으로 XOR의 복원 성질을 보여 줄 수 있다 - [ ] 바이너리 모드(
"rb"/"wb")로 파일을 읽고 쓸 수 있다 - [ ] enc/dec이 동작하는 CLI 도구를 완성했다
- [ ] 왕복 결과가 원문과 같음을
cmp로 증명했다 - [ ] 256번 전수 시도로 자기 암호문을 깼다
- [ ] 키 재사용 공격(M1 ⊕ M2 노출)을 설명할 수 있다
- [ ] 이 암호가 약한 이유(키 공간, 키 반복)를 말할 수 있다
6. 흔한 실수와 해결
벽 1. 복원 파일이 깨진다 / UnicodeDecodeError가 난다
증상 (2026-09-09 실측, 암호문을 텍스트 모드로 읽은 경우):
UnicodeDecodeError: 'cp949' codec can't decode byte 0xfe in position 6: illegal multibyte sequence
원인: open에 b를 빠뜨려 텍스트 모드로 읽은 것입니다. 텍스트 모드는 운영체제가 인코딩(한국어 Windows는 cp949)을 해석하다가 바이너리 바이트에서 죽거나 바꿔 버립니다.
해결: 읽기·쓰기 모두 "rb"/"wb"인지 확인하세요. 이 b 하나가 이 챕터 최다 사고입니다.
벽 2. "ValueError: invalid literal for int()"가 난다
증상 (2026-09-09 실측, 키 자리에 key라는 글자를 넣은 경우, 경로는 가공):
File "xor_tool.py", line 10, in main
mode, path, key = sys.argv[1], sys.argv[2], int(sys.argv[3])
ValueError: invalid literal for int() with base 10: 'key'
원인: int(sys.argv[3])에 숫자가 아닌 글자가 들어왔습니다.
해결: 사용법대로 숫자(0~255)를 넣으세요. 익숙해지면 try/except로 감싸 친절한 안내를 띄우는 것도 좋은 연습입니다.
벽 3. crack.py가 아무 후보도 못 찾는다
증상: 256번 돌렸는데 조용합니다.
원인: 암호문에 찾으려는 단어(hello)가 없거나, 그 단어가 원문에 없었습니다.
해결: 원문에 실제로 있는 단어로 찾으세요. 실전에서는 "영어 문장처럼 보이는가"에 점수를 매기는 방식을 씁니다 — 짧은 단어 하나가 우연히 맞는 오탐도 있으니 출력된 후보를 눈으로 확인하는 것까지가 크래킹입니다.
벽 4. 틀린 키인데 오류가 안 나와서 모른 채 넘어간다
증상: 복호화가 "성공"했는데 내용이 이상합니다.
원인: XOR은 틀린 키에도 오류 없이 틀린 답을 줍니다 (3-5 실측: 키가 1 어긋나면 각 글자가 1씩 어긋난 "idmmn" 같은 결과).
해결: 도구는 결과를 검증할 수 없습니다. 복원 후 내용을 눈으로 확인하는 습관 — 원본이 있다면 cmp로 대조하는 것이 정석입니다.
벽 5. 파일 이름이 눈덩이처럼 붙는다 (.enc.enc.dec…)
증상: 반복 실행할수록 확장자가 쌓입니다.
원인: 출력 이름을 단순히 이어 붙이는 방식의 한계입니다.
해결: dec 모드에서는 끝의 .enc를 떼어내는 식으로 이름 규칙을 다듬어 보세요. 이 마무리까지가 "도구의 완성도"입니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| XOR (⊕) | 같으면 0, 다르면 1. A ⊕ K ⊕ K = A — 이 성질이 암호를 만든다 |
| 대칭키 암호 | 잠글 때와 열 때 같은 열쇠를 쓰는 암호 |
| 바이너리 모드 | "rb"/"wb" — 모든 파일은 0~255 숫자의 나열 |
| 키 공간 | 가능한 키의 개수. 256개면 전수 시도 1초, 2^128이면 불가능 |
| 전수 시도(브루트포스) | 키 후보를 전부 돌려 읽히는 것을 고르는 공격 |
| 알려진 평문 공격 | 평문 일부를 알면 키가 복구되는 공격 — 키 재사용 시 치명적 |
| OTP | 키가 데이터만큼 길고 무작위이며 한 번만 쓰이는, 수학적으로 깰 수 없는 유일한 암호 |
오늘의 코드
| 코드 | 하는 일 |
|---|---|
a ^ b |
두 숫자의 XOR |
bytes([b ^ key for b in data]) |
바이트 나열 전체를 키로 뒤집기 (암호화=복호화) |
open(p, "rb").read() |
파일을 바이트 나열로 읽기 |
open(p, "wb").write(b) |
바이트 나열을 파일로 쓰기 |
sys.argv[1:] |
명령행 인자 꺼내기 |
for key in range(256) |
한 바이트 키 전수 시도 |
cmp 원본 복원본 |
왕복 검증 (무출력=일치, Windows는 fc /b) |
명령어보다 중요한 감각
오늘 여러분은 만들고, 되돌리고, 스스로 깼습니다. XOR 한 줄이 암호가 되고, 키 공간 256이 그 암호의 무덤이 되며, 키 재사용이 암호문 두 개를 맞물려 평문을 새어 나가게 한다는 것까지 전부 손으로 확인했습니다. "약한 암호를 약한 줄 알고 쓰는 것"과 "모르고 쓰는 것"의 차이가 전문가와 아마추어의 차이입니다.
그리고 이 한 줄짜리 연산에는 위대한 후손이 있습니다 — 키가 데이터와 같은 길이이고 완전히 무작위이며 딱 한 번만 쓰이면, 수학적으로 깨는 것이 불가능함이 증명된 유일한 암호 OTP(일회용 암호표)입니다. 오늘의 장난감이 그 암호의 조상입니다. CTF 크립토 문제의 상당수가 "뭔가 XOR해 둔 것"의 변주곡이니, crack.py를 위키에 잘 보관해 두세요 — 대회장에서 그대로 꺼내 쓰는 날이 옵니다.
전부 체크되면 Step 90 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.