[🌱 새싹] 이름은 base64, 정체는 base32 — DreamHack likeb64 풀이
target.txt 안에 base64처럼 생긴 문자열 하나만 주어진다. 그런데 '=' 패딩이 3개고, 데이터 길이가 4의 배수에서 하나 어긋나며, 숫자와 +/ 기호가 하나도 없다. base64 규칙으로는 나올 수 없는 출력이다. 문제 설명이 흘린 32글자 알파벳을 단서로 이것이 커스텀 알파벳 base32(숫자 2-7 자리를 a-f로 바꾼 것)임을 밝히고, 알파벳만 되돌려 복호한다.
"드림이가 base64를 공부하고 자신만의 암호를 만들었어요." 문제 설명 한 줄과 target.txt 안의 문자열 하나가 전부다. 이름이 likeb64라 당연히 base64로 풀리겠거니 하고 손을 대면, 파이썬이 Invalid base64-encoded string을 뱉으며 처음부터 발목을 잡는다. 이름값에 속지 않고 문자열의 생김새를 뜯어보면 정체가 드러난다.
문제 개요
항목
내용
문제명
likeb64
난이도
🌱 새싹
분류
Crypto
제공 파일
target.txt (암호문 문자열 1개)
목표
암호문 복호 → 플래그
핵심 기법
커스텀 알파벳 base32 — 표준의 숫자 2-7 자리를 a-f로 치환
이름은 likeb64지만, 결론부터 말하면 base64가 아니라 base32다. 그것도 알파벳의 마지막 6칸만 살짝 바꿔치기한 것이라, 그 사실만 알아채면 한 줄로 복호된다. 정작 문제는 "이게 base32다"라는 걸 눈치채는 관찰의 과정에 있다.
배경 — base64 와 base32 는 뭐가 다른가
둘 다 바이너리를 아스키 텍스트로 옮기는 인코딩이지만, 한 글자에 몇 비트를 담느냐가 다르다.
base64: 6비트씩 끊는다. 알파벳은 64칸(A-Z a-z 0-9 + /). 원본 3바이트(24비트)가 정확히 4글자(24비트)로 떨어져서, 출력은 4글자 단위로 묶인다. 24비트에 안 맞는 꼬리는 = 하나나 둘로 채운다. 그래서 base64의 = 패딩은 최대 2개다.
base32: 5비트씩 끊는다. 알파벳은 32칸, RFC 4648 표준으로는 ABCDEFGHIJKLMNOPQRSTUVWXYZ234567이다. 원본 5바이트(40비트)가 8글자(40비트)로 떨어져서, 출력은 8글자 단위로 묶인다. 40비트에 안 맞는 꼬리는 =로 채우는데, 이때 패딩 개수는 원본 길이에 따라 6·4·3·1개가 나올 수 있다.
여기서 base32 알파벳을 다시 보자. 앞 26칸은 A-Z, 뒤 6칸은 **숫자 2-7**이다. base64와 달리 소문자도 없고 +, /도 없다. 만약 누군가 이 뒤쪽 숫자 6칸을 다른 문자로 바꿔치기하면, 겉보기 문자셋만 달라질 뿐 인코딩 원리는 그대로다. 이 문제가 딱 그 장난을 쳤다.
🔬 정찰 — 받은 건 문자열 하나
압축을 풀면 target.txt 하나뿐이다. 먼저 파일을 눈으로 보고, 길이·패딩·꼬리 바이트를 센다.
target.txt 원문과 꼬리 바이트 — 전체 112자, 끝이 G O J Q M Y Z H a = = = 로 등호 패딩 3개
문자열은 112자, 끝은 ===로 패딩이 3개다. 여기서 이미 base64가 아니다. base64의 =는 아무리 많아야 2개이기 때문이다. 좀 더 정량적으로 뜯어보려고 짧은 정찰 스크립트를 하나 돌렸다.
# why_b32.py — 정말 base64 인지 검증import collectionsct = open("target.txt").read().strip()data = ct.rstrip("=")npad = len(ct) - len(data)print(f"전체 길이 : {len(ct)}")print(f"패딩 '=' 개수 : {npad} (base64는 최대 2, 3은 불가 → base64 아님)")print(f"데이터 길이 : {len(data)} ({len(data)}%4={len(data)%4} → base64 그룹(4) 안 맞음)")print(f"distinct 심볼 : {len(set(data))}개 {''.join(sorted(set(data)))}")print(f"숫자/+// 유무 : {any(c.isdigit() for c in data)} / {any(c in '+/' for c in data)}")print()print("== base32 로 보면 딱 맞는다 ==")print(f"112 = 8 × 14 그룹 (base32는 8문자 그룹)")print(f"데이터 {len(data)}자 = 13×8 + 5 → 13×5 + 3 = 68 바이트 (플래그 길이와 일치)")
base64 부정 3근거와 base32 정합성 — 패딩3·데이터109(109%4=1)·숫자와 +/ 전무, 112는 8글자 그룹 14개에 데이터 109자는 68바이트로 딱 떨어짐
정리하면 세 가지가 전부 base64를 부정한다.
= 패딩이 3개 — base64는 최대 2개.
데이터가 109자이고 109 % 4 == 1 — base64는 4글자 그룹이라 데이터 길이가 4로 나눠떨어지거나 그룹 안에서 1·2 모자란 정도여야 한다. 하나 남는 형태(% 4 == 1)는 base64가 애초에 만들 수 없는 길이다.
숫자도 +, /도 전혀 없다 — 실제 데이터를 base64로 인코딩했다면 숫자와 기호가 골고루 섞여 나온다. 문자셋이 대문자 A-Z에 소문자 a, b, c 정도만 있는 건 부자연스럽다.
반면 base32로 보면 전부 맞아떨어진다. 112자는 8글자 그룹 14개, 패딩 3개는 base32에서 유효한 개수, 데이터 109자를 5비트씩 풀면 정확히 68바이트가 나온다. 그리고 DH{ + 64자리 hex + } = 68자, 즉 플래그 본문이 64자리 hex(sha256으로 추정)라는 것까지 길이가 맞는다.
▶ 삽질: 이름값 하느라 base64로만 씨름하고, 치환암호로 크래킹하려다 헛발질
처음엔 이름 그대로 base64로 밀어붙였다. 표준 그대로, = 떼고 패딩 다시, swapcase 후 등 변주를 줘봐도 전부 같은 예외에서 멈춘다.
coreutils/python base64 세 가지 시도 모두 실패 — number of data characters (109) cannot be 1 more than a multiple of 4
number of data characters (109) cannot be 1 more than a multiple of 4. 파이썬이 대놓고 "109는 4의 배수보다 1 많아서 안 된다"고 알려준다. 이 메시지 자체가 힌트였는데, 처음엔 알파벳이 커스텀 base64겠거니 하고 엉뚱한 방향으로 샜다.
그다음 삽질은 "커스텀 알파벳 base64를 치환암호로 복원하자"였다. 알려진 평문 DH{의 표준 base64가 REh7이니, 암호문 앞 4글자와 대응시켜 Vigenère 키(주기 4)를 추정하거나, 6비트 치환 테이블을 시뮬레이티드 어닐링으로 맞춰보려 했다.
# vig.py — DH{ 의 표준 b64 "REh7" 를 앵커로 주기4 Vigenère 키 추정 (실패)STD = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"data = open("target.txt").read().strip().rstrip("=")key = [(STD.find(data[i]) - STD.find("REh7"[i])) % 64 for i in range(4)]print("key(period4 guess):", key) # 복원 결과가 전혀 printable 하지 않음
전부 헛수고였다. 애초에 base64(6비트)가 아니라 base32(5비트)였으니, 6비트 단위로 아무리 치환을 맞춰봐야 비트 경계부터 어긋난다. 문제 설명이 흘린 힌트를 제대로 읽고 나서야 방향을 틀었다.
결정적 단서 — 설명이 흘린 32글자 알파벳
문제 설명 맨 아래에 힌트가 하나 붙어 있다.
hint: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef
세어 보면 정확히 32글자다. A부터 Z까지 26글자에 a b c d e f 6글자를 붙인 것. 32칸짜리 알파벳 = base32. 이름은 likeb64("base64 같다")지만, 실제 정체는 알파벳만 갈아 끼운 base32였던 것이다. 표준 base32와 나란히 놓으면 차이가 한눈에 보인다.
표준 base32 : ABCDEFGHIJKLMNOPQRSTUVWXYZ234567likeb64 : ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef ^^^^^^ 뒤 6칸만 234567 → abcdef
앞 26칸 A-Z는 그대로고, 값 26~31에 해당하는 마지막 6칸만 표준의 2 3 4 5 6 7을 a b c d e f로 바꿨다. 암호문에 숫자가 하나도 없던 이유가 바로 이것이다. 숫자가 나올 자리를 소문자로 위장해 "base64처럼" 보이게 만든 것.
likeb64 전체 논리 다이어그램 — base64가 아닌 3근거, base32 정합성, 커스텀 알파벳(234567→abcdef) 치환, 그리고 복호 결과
base32 5비트 패킹으로 앵커 검증
가설을 세웠으면 알려진 평문으로 못을 박는다. 플래그는 항상 DH{로 시작하니, 이 3바이트를 표준 base32로 인코딩해 암호문의 시작과 맞춰보면 된다. base32가 5비트씩 어떻게 끊는지 손으로 따라가 봤다.
# bits.py — 'DH{' 3바이트(24비트)를 5비트씩 끊어 base32 인덱스로CUSTOM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef"pt = b"DH{"bits = "".join(f"{b:08b}" for b in pt) # 24비트groups = [bits[i:i+5] for i in range(0, 25, 5)] # 5비트 5칸(마지막은 1비트+0패딩)groups[-1] = groups[-1].ljust(5, "0")idx = [int(g, 2) for g in groups]print("비트(24) :", bits)print("5비트 그룹 :", " ".join(groups))print("→ 인덱스 :", idx) # [8, 17, 4, 7, 22]print("→ likeb64 :", "".join(CUSTOM[i] for i in idx)) # IREHW
평문 DH 세 글자를 24비트로 이어 5비트 5그룹으로 분해해 인덱스 8,17,4,7,22 를 얻고 IREHW 로 인코딩, 암호문 시작 IREHWYJZ 와 앞 5글자 일치
DH{(0x44 0x48 0x7b)를 24비트로 이어붙여 5비트씩 끊으면 인덱스 [8, 17, 4, 7, 22], 알파벳에 대입하면 IREHW가 나온다. 그리고 암호문은 IREHWYJZ…로 시작한다. 앞 5글자가 정확히 일치한다. 이 알파벳으로 base32를 돌리면 된다는 확증이다. 표준 라이브러리로도 한 번 더 확인했다.
base32 로 DH 접두사를 인코딩하면 IREHW 로 시작해 암호문과 일치, 표준 알파벳(끝 234567)과 likeb64 알파벳(끝 abcdef) 비교로 숫자가 없던 이유 확인
복호 — 알파벳만 되돌리면 끝
이제 할 일은 단순하다. 커스텀 알파벳 …abcdef를 표준 base32 알파벳 …234567로 문자 치환한 뒤, 그냥 표준 base32 디코더에 넣으면 된다. 파이썬 base64.b32decode가 표준 알파벳을 가정하므로, str.translate로 6글자만 되돌려 주면 그대로 먹는다.
플래그 본문은 64자리 hex, DH{}까지 합쳐 68자다. 앞서 정찰에서 계산한 "데이터 109자 → 68바이트"와 정확히 맞아떨어진다.
다른 도구로 교차검증
치환 규칙이 워낙 단순해서 파이썬 없이도 확인된다. 커스텀 알파벳과 표준의 차이는 소문자 a-f가 2-7을 대신한다는 것뿐이니, tr로 그 6글자만 되돌린 다음 coreutils의 base32 -d에 흘려보내면 된다. 대문자 A-Z는 두 알파벳에서 동일해 건드릴 필요가 없다.
coreutils base32 CLI 교차검증 — tr 로 a-f 를 234567 로 되돌린 뒤 base32 -d 로 디코드해 동일한 DH 플래그 출력
브라우저에서 즐겨 쓰는 CyberChef로도 같은 결과를 얻는다. From Base32 연산은 알파벳을 직접 지정할 수 있어서, Alphabet 칸에 ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef=를 그대로 넣고 암호문을 Input에 붙이면 Output에 플래그가 뜬다. 커스텀 알파벳을 알 때 별도 스크립트 없이 검증하기 편하다.
인코딩(base32/base64)은 기밀성을 주지 않는다. 알파벳만 알면 누구든 되돌린다. 여기서 "자신만의 알파벳"으로 살짝 비틀어도, 알파벳 32~64글자를 문제 설명에 흘리거나 알려진 평문 몇 글자로 역산되는 순간 무용지물이다. 민감 데이터를 base64/base32로 "가려" 두는 건 보안이 아니라 그냥 눈속임이다.
겉보기에 속지 말고 구조를 읽어야 한다. 이름(likeb64)과 겉모양(= 패딩, 대소문자)만 보면 base64로 오인하기 쉽다. 그러나 패딩 개수, 데이터 길이의 나머지, 등장 문자셋(숫자·기호 유무) 같은 정량적 지표가 인코딩의 종류를 정확히 가려낸다. base64는 = 최대 2개·4글자 그룹, base32는 = 6/4/3/1개·8글자 그룹이라는 규칙만 기억해도 오인을 피한다.
알려진 평문은 강력한 검증 수단이다. 플래그 접두사 DH{처럼 확정된 조각이 있으면, 가설로 세운 알파벳/인코딩이 맞는지 단 몇 글자로 즉시 확인할 수 있다.
likeb64라는 이름은 낚시고, 정체는 알파벳 6칸만 바꾼 base32였다. 이름값에 끌려가지 않고 문자열의 생김새를 세어 본 것이 풀이의 전부였다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…