인코딩, 해싱, 암호화는 서로 다른 세 가지입니다
뒤죽박죽으로 보이는 문자열이 곧 보호된 문자열은 아닙니다. 읽을 수 없는 출력을 만들어내는 연산은 서로 다른 세 가지가 있고, 이들을 혼동하는 데서 실제 보안 결함이 나옵니다.
| 되돌릴 수 있는가 | 키가 필요한가 | 무엇을 위한 것인가 | |
|---|---|---|---|
| 인코딩 | 예, 누구나 | 아니요 | 원시 바이트를 실어 나르지 못하는 통로로 바이트를 통과시키기 |
| 해싱 | 아니요 | 아니요 | 지문, 무결성 검사, 조회용 키 |
| 암호화 | 예, 키가 있으면 | 예 | 기밀성 |
인코딩은 알파벳을 바꾸는 일입니다. Base64, 퍼센트 인코딩, 16진수, HTML 엔터티는 같은 정보를 다르게 적어서 뒤쪽의 파서가 그 정보에 걸려 넘어지지 않게 합니다. 비밀이 개입하지 않으므로 지킬 것도 없습니다. 카이사르 암호도 여기에 속합니다. 카이사르 암호 도구는 25가지 이동을 한 번에 전부 시도해 보는데, 고정된 치환이 얼마나 보호해 주는지를 잘 요약해 줍니다. 해싱은 임의의 입력을 고정 길이의 다이제스트로 대응시키며 거꾸로 실행할 수 없습니다. 이 셋 중 기밀성을 제공하는 것은 암호화뿐이며, 암호화에는 언제나 키가 있습니다. 키를 짚어 보일 수 없다면 암호화되고 있는 것은 없습니다.
Base64와 그 비용
Base64는 3바이트(24비트)를 6비트씩 네 묶음으로 나누고, 각 묶음을 64개 문자 알파벳의 한 문자로 적습니다. 3바이트당 4문자이므로 출력은 3분의 1만큼 커집니다. 이것이 가장 크게 문제가 되는 곳은 파일을 문서 안에 끼워 넣을 때입니다. 파일 Base64 변환 도구는 데이터 URL을 만들어내므로, 300 KB짜리 이미지는 HTML 안에 대략 400 KB로 들어가고 페이지를 열 때마다 다시 내려받게 됩니다.
입력이 3바이트의 배수가 아니면 마지막 묶음은 =로 채워집니다.
"abc" -> YWJj (3 bytes, no padding)
"ab" -> YWI= (2 bytes, one =)
"a" -> YQ== (1 byte, two =)
표준 알파벳은 +와 /로 끝나는데, URL에서는 이것이 문제입니다. +는 폼 데이터에서 공백으로 디코딩되고 /는 경로 구분자이기 때문입니다. 그래서 두 번째 알파벳이 존재합니다. Base64url은 +를 -로, /를 _로 바꾸고 대개 패딩을 뺍니다. URL에서 복사한 토큰이 표준 알파벳으로 설정된 디코더에서 종종 실패하는 이유가 이것입니다. Base64 인코더는 두 알파벳을 모두 처리하고 유니코드도 올바르게 다룹니다. 단순한 구현은 그러지 못하는 경우가 많은데, 텍스트는 Base64 단계 전에 UTF-8로 인코딩되어야 합니다.
이 중 어느 것도 보안 수단이 아닙니다. 어떤 제품이 URL 안의 "암호화된" 식별자라고 부르는 값이, 실제로는 누구나 몇 초 만에 읽고 바꾸고 다시 인코딩할 수 있는 평범한 정수의 Base64인 경우가 아주 많습니다. JSON Web Token의 페이로드도 마찬가지입니다. JWT 디코더는 키 없이 그 내용을 읽습니다. 잠금을 풀 것이 애초에 없기 때문입니다.
퍼센트 인코딩, 그리고 어떤 함수를 쓸 것인가
퍼센트 인코딩은 바이트 하나를 %와 16진수 두 자리로 바꿉니다. 문자가 아니라 바이트 단위로 동작하므로 ASCII가 아닌 텍스트는 먼저 UTF-8로 인코딩됩니다. é는 %C3%A9가 됩니다.
비예약 문자(A-Z, a-z, 0-9, -, ., _, ~)는 인코딩할 필요가 전혀 없습니다. 예약 문자(: / ? # [ ] @ ! $ & ' ( ) * + , ; =)는 URL에 구조를 부여하는 구분자이며, 인코딩이 필요한지 여부는 그 문자가 구분자로 쓰이는지 데이터로 쓰이는지에 달려 있습니다. 두 JavaScript 함수의 차이가 바로 이것입니다.
encodeURI("https://ex.com/s?q=cats & dogs")
// https://ex.com/s?q=cats%20&%20dogs the & still splits the query
encodeURIComponent("cats & dogs")
// cats%20%26%20dogs safe as a single value
encodeURI는 URL 전체를 적법하게 만들기 위한 함수이므로 구분자는 그대로 둡니다. encodeURIComponent는 경로 세그먼트나 쿼리 매개변수로 들어갈 값 하나를 위한 함수이므로 구분자까지 인코딩합니다. 어딘가에 끼워 넣을 값에는 두 번째를 쓰십시오. 다만 이 함수도 !, ', (, ), *은 그대로 둔다는 점을 알아두십시오.
그리고 더하기 기호가 있습니다. application/x-www-form-urlencoded로 보내는 폼 본문은 공백을 +로 인코딩하는데, 이 관례가 쿼리 문자열로 새어 나가서 대부분의 서버가 쿼리 안의 +를 공백으로 디코딩합니다. 문자 그대로의 더하기는 %2B로 보내야 하며, alex+billing@example.com이 그토록 자주 태그가 망가진 alex billing@example.com으로 도착하는 이유가 이것입니다. 반면 경로 세그먼트에서 +는 그냥 더하기이고 공백은 %20입니다. URL 인코더를 쓰면 어떤 값이 한 번의 왕복은 견디고 그다음에 깨지는 상황을 눈으로 확인할 수 있습니다.
이스케이프는 값이 놓이는 자리에 따라 달라집니다
"웹용으로 이스케이프된" 문자열이라는 단일한 형태는 없습니다. 문맥이 결정합니다.
| 문맥 | 올바른 처리 |
|---|---|
| HTML 텍스트 | &, <, >를 엔터티로 |
| 따옴표로 감싼 속성 | &와 감싸는 데 쓴 따옴표를 엔터티로, 그리고 속성은 항상 따옴표로 감쌀 것 |
<script> 안 | 엔터티가 아니라 JavaScript 문자열 이스케이프 |
href나 src의 URL | 값을 퍼센트 인코딩하고 속성 이스케이프한 다음 스킴을 확인 |
| CSS 값 | CSS 이스케이프, 그리고 사용자 입력으로 url()을 만들지 말 것 |
스크립트 경우가 사람들의 발목을 잡습니다. 스크립트 내용은 엔터티 디코딩되지 않으므로 거기에 쓴 엔터티는 문자 그대로 남고, HTML 파서는 따옴표 안이든 아니든 </script라는 시퀀스가 나오는 자리에서 블록을 닫아버립니다.
<script>var s = "</script>";</script> <!-- block ends early -->
<script>var s = "<\/script>";</script> <!-- correct -->
URL에 대해서는 이스케이프만으로 충분하지 않습니다. javascript:로 시작하는 값은 아무리 조심스럽게 인코딩했더라도 클릭하면 실행되므로, 스킴을 허용 목록과 대조해 검증하십시오. HTML 엔터티 도구는 텍스트와 속성 경우를 모두 다루고 엔터티를 다시 디코딩하기도 하는데, 어떤 API가 무언가를 이중으로 이스케이프해서 &amp;로 만들어 놓았을 때 흔히 필요한 기능입니다.
해싱, 어떤 알고리즘을 무엇에 쓸 것인가
| 알고리즘 | 다이제스트 | 상태 |
|---|---|---|
| MD5 | 128비트 | 깨졌습니다. 충돌을 만드는 일이 사소합니다 |
| SHA-1 | 160비트 | 깨졌습니다. 선택 접두사 충돌이 현실적이고 비용도 감당할 만합니다 |
| SHA-256, SHA-512 | 256비트, 512비트 | 무결성과 서명에는 문제없습니다 |
| bcrypt, scrypt, Argon2 | 가변 | 비밀번호 전용 |
MD5와 SHA-1은 충돌 저항성에서 실패했습니다. 공격자가 같은 다이제스트를 갖는 서로 다른 두 입력을 만들어낼 수 있다는 뜻입니다. 해시가 문서를 대신하는 자리라면 어디에서도 쓸 수 없습니다. 서명, 인증서, 콘텐츠 주소 지정, 공격자가 제공한 데이터의 중복 제거가 그런 자리입니다. 역상 저항성은 둘 다 아직 깨지지 않았으므로 오래된 MD5 체크섬은 여전히 우연한 손상을 잡아내지만, 어느 쪽도 새로운 작업에 들어갈 자리는 없습니다. SHA-256을 쓰십시오. 해시 생성기는 텍스트뿐 아니라 파일에 대해서도 이 값을 계산합니다.
비밀번호는 다른 문제이며, 빠른 해시는 빠르다는 바로 그 이유 때문에 잘못된 도구입니다. 흔한 하드웨어도 초당 수십억 번의 SHA-256 연산을 수행하므로, 유출된 SHA-256 비밀번호 해시 표는 곧 유출된 비밀번호 표입니다. bcrypt, scrypt, Argon2id는 의도적으로 느리고 조절 가능하며, 하드웨어가 좋아질수록 올릴 수 있는 작업 계수(뒤의 둘은 메모리 비용까지)를 갖고 있고, 각자 고유한 무작위 솔트를 출력에 함께 저장합니다.
해시는 서명도 아닙니다. 메시지가 키를 가진 사람에게서 왔음을 증명하려면, 비밀값과 메시지를 이어 붙여 해싱하는 대신 HMAC-SHA-256을 쓰십시오. 이어 붙이는 방식은 길이 확장 공격에 취약합니다.
해시가 할 수 없는 일
해시는 계산으로 되돌릴 수 없습니다. 하지만 가능한 입력의 집합이 전부 나열할 수 있을 만큼 작다면 언제든 탐색으로 되돌릴 수 있습니다. 네 자리 PIN을 모두 해싱하면 1만 개의 다이제스트가 즉시 손에 들어오고, 유출 목록에 있는 모든 우편번호, 전화번호, 주소도 마찬가지입니다. 식별자를 해싱한다고 익명화되지는 않습니다.
솔팅, 즉 레코드마다 고유한 무작위 값을 함께 저장하고 입력에 섞어 넣는 방식은 특정한 한 대상을 추측하기 어렵게 만들어 주지는 않지만, 공격자가 모든 레코드를 따로따로 공격하도록 강제하고 미리 계산해 둔 표를 쓸모없게 만듭니다. 엔트로피가 낮은 값에는 페퍼, 즉 데이터베이스 바깥에 보관하는 비밀 키가 실제로 도움이 되는 부분입니다.
정체를 알 수 없는 문자열 알아보기
| 형태 | 단서 | 예시 |
|---|---|---|
| 16진수 | 0-9a-f만 쓰고 길이가 짝수. 32자면 MD5, 40자면 SHA-1, 64자면 SHA-256 | 5d41402abc4b2a76b9719d911017c592 |
| Base64 | 대소문자가 섞여 있고 +와 /가 있으며 길이가 4의 배수. =나 ==로 끝날 수 있음 | SGVsbG8sIHdvcmxkIQ== |
| Base64url | 위와 같지만 -와 _를 쓰고 보통 패딩이 없음 | eyJzdWIiOiIxMjM0NSJ9 |
| 퍼센트 인코딩 | % 뒤에 16진수 두 자리 | a%20b%26c |
| JWT | 마침표로 구분된 Base64url 덩어리 세 개이며 eyJ로 시작 | eyJhbGciOi... |
| bcrypt | 정확히 60자이며 $2a$, $2b$, $2y$와 비용 값으로 시작 | $2b$12$... |
| Argon2 | $argon2id$v=19$m=...,t=...,p=...$salt$hash |
eyJ는 외워둘 만합니다. 이것은 {"의 Base64이므로, 그렇게 시작하는 덩어리는 토큰이든 아니든 인코딩된 JSON 객체입니다.
디코딩이 성공했다는 사실이 추측이 맞았다는 증거는 아닙니다. Base64는 어떤 입력이든 바이트로 바꿔주기 때문입니다. 결과가 그럴듯한 텍스트인지, 아니면 알려진 파일 시그니처로 시작하는지 확인하십시오. iVBORw0KGgo로 시작하는 Base64는 PNG, /9j/는 JPEG, JVBERi0는 PDF, UEsDB는 zip입니다. 바이트가 아무것도 아닌 것처럼 보인다면 출력을 텍스트 이진 변환 도구에 넣어 코드를 직접 읽어보거나, 그 값이 Base64로 인코딩되기 전에 퍼센트 인코딩되었던 것은 아닌지 확인하십시오. 흔한 이중 포장 방식입니다.