개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

기초개념 › 보안

해시

해시는 무엇이고, 비밀번호를 왜 해시로 저장하는가

해시 함수는 어떤 글자를 넣든 정해진 길이의 뒤섞인 값을 내놓는 계산입니다. 코드의 sha256("pass1234")는 SHA-256이라는 해시 함수에 pass1234를 넣는 줄이고, 나오는 값은 0-9와 a-f 글자 64개입니다. 해시 함수에는 세 가지 성질이 있습니다. 같은 글자를 넣으면 언제나 같은 값이 나오고, 한 글자만 달라도 전혀 다른 값이 나오고, 나온 값에서 원래 글자를 거꾸로 계산해 낼 수 없습니다. 그래서 서버는 가입할 때 비밀번호 자체가 아니라 그 해시 값을 저장하고, 로그인할 때는 입력한 글자를 같은 방법으로 해시해서 저장된 값과 같은지만 봅니다. 이 실습은 비밀번호를 해시해서 저장하는 서버와 그대로 저장하는 서버에, 맞는 비밀번호, 한 글자 다른 비밀번호, 저장된 값 자체를 입력해 보며 로그인이 어떻게 되는지를 봅니다. (실제 서비스는 비밀번호를 SHA-256으로 저장하지 않고 전용 함수를 씁니다. 자세한 것은 아래 설명을 보세요.)

실행으로 대조 · JDK 21에서 여섯 가지 조합을 javax.tools로 컴파일하고 실행해 대조. SHA-256 값은 표준 예시, Node.js, openssl과도 견줌

이 실습에 나오는 개념

해시 함수
해시 함수는 어떤 길이의 글자(데이터)를 넣든 정해진 길이의 뒤섞인 값을 내놓는 계산이고, 그 값을 해시 값 또는 다이제스트라고 한다. SHA-256은 입력이 1글자든 100글자든 256비트, 곧 16진수 글자 64개의 값을 낸다. 같은 입력에는 언제나 같은 값이 나오고, 입력이 조금만 달라져도 매우 높은 확률로 전혀 다른 값이 나온다. 이 용어는 SHA-256 같은 암호용 해시 함수를 가리키며, 자료구조 HashMap에서 쓰는 해시 코드와는 쓰임이 다르다.
단방향
해시 함수가 단방향이라는 것은 입력에서 값은 쉽게 계산하지만, 나온 값에서 원래 입력을 거꾸로 찾아내는 것은 현실적으로 불가능하도록 만들어졌다는 뜻이다. NIST의 표준은 이 함수들을 안전하다고 부르는 이유를, 주어진 해시 값에 맞는 입력을 찾는 것과 같은 해시 값을 내는 서로 다른 두 입력을 찾는 것이 계산상 불가능하기 때문이라고 설명한다. 이것은 수학적으로 증명된 것이 아니라 알려진 방법이 없다는 뜻이다. 단방향은 암호화와 다르다. 암호화는 열쇠가 있으면 원래대로 풀 수 있지만 해시에는 푸는 열쇠가 없다. 또 짧고 흔한 입력은 후보를 하나씩 해시해서 견주는 방법으로 찾아질 수 있다.
비밀번호 해시 저장
비밀번호 해시 저장은 가입할 때 비밀번호 자체가 아니라 그 해시 값을 저장하는 방법이다. 로그인할 때는 입력한 글자를 같은 해시 함수에 넣어, 나온 값이 저장된 값과 같으면 입력이 가입 때의 비밀번호와 같다고 본다. 서버는 원래 비밀번호를 몰라도 맞는지 확인할 수 있고, 저장된 값이 새어 나가도 원래 비밀번호는 바로 드러나지 않으며 그 값을 비밀번호 칸에 넣어도 한 번 더 해시되어 다른 값이 되므로 로그인되지 않는다. OWASP는 비밀번호를 평문으로 저장하지 말고 해시해서 저장하라고 하되, SHA-256 같은 빠른 해시 함수를 그대로 쓰는 것은 맞지 않고, Argon2id 같은 느리게 만든 비밀번호 전용 함수에 사용자마다 고유한 솔트를 더해 쓰라고 한다.
해시 함수와 솔트
해시 함수는 입력을 고정된 길이의 값으로 바꾸는 함수로, 결과에서 원래 입력을 되돌릴 수 없다. 비밀번호는 원문 대신 해시 값을 저장해 유출되더라도 원문이 드러나지 않게 한다. 솔트는 비밀번호마다 다르게 덧붙이는 무작위 값으로, 같은 비밀번호라도 다른 해시가 되게 해 미리 계산해 둔 해시 목록을 이용한 공격을 막는다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. String saved = sha256("pass1234"); 줄이 실행됩니다. 가입하는 순간, 서버가 사용자가 정한 비밀번호 "pass1234"를 해시 함수에 넣습니다. 해시 함수는 어떤 글자를 넣든 정해진 길이의 뒤섞인 값을 내놓는 계산이고, 여기서는 그중 SHA-256을 씁니다. sha256은 이 계산을 해 주는 도우미 메서드입니다(코드는 줄였습니다).
  2. 계산 결과로 뒤섞인 64자 값이 나옵니다. 0-9와 a-f 글자 64개이고, 앞 12자는 bd94dcda26fc입니다. 입력이 1글자든 100글자든 나오는 값은 언제나 64자이고, 같은 글자를 넣으면 언제 계산해도 같은 값이 나옵니다.
  3. 서버는 이 64자 값만 saved에 저장합니다. 원래 비밀번호 pass1234는 저장하지 않습니다. 해시 함수는 나온 값에서 원래 글자를 찾아내는 것이 현실적으로 불가능하도록 만들어진 함수여서, 저장된 값을 거꾸로 계산해서는 pass1234를 알 수 없습니다.
  4. String input = "pass1234"; 줄이 실행됩니다. 이제 로그인입니다. 사용자가 비밀번호 칸에 pass1234를 입력합니다.
  5. sha256(input)이 실행됩니다. 서버가 입력된 글자를 가입 때와 똑같은 해시 함수에 넣습니다. 서버는 가입 때의 비밀번호를 가지고 있지 않으므로, 입력을 같은 방법으로 해시해서 저장된 값과 견주는 수밖에 없습니다.
  6. 나온 값은 bd94dcda26fc…로, 가입 때 pass1234에서 나온 값과 같습니다. 같은 글자를 넣으면 언제나 같은 값이 나오기 때문입니다.
  7. sha256(input).equals(saved)가 실행됩니다. 입력에서 계산한 값과 저장된 값을 견주면 같아서 ok는 true입니다. 같다는 것은 입력이 가입 때의 비밀번호와 같았다는 뜻이므로 로그인에 성공합니다. 서버는 원래 비밀번호를 저장해 두지 않았는데도, 해시 값끼리 견주는 것만으로 맞는지 확인했습니다.
  8. System.out.println(saved)가 저장된 값 64자를, System.out.println(ok)가 true를 출력합니다.

실행 결과

코드

// 가입할 때: 비밀번호 자체가 아니라 해시한 값을 저장한다
String saved = sha256("pass1234");

// 로그인할 때: 입력을 같은 방법으로 해시해서 저장된 값과 견준다
String input = "pass1234";
boolean ok = sha256(input).equals(saved);

System.out.println(saved);
System.out.println(ok);

이 모형의 전제

컴파일러와 실행 환경은 JDK 21.0.12.1(javac 21)입니다. 여섯 가지 조합을 모두, 화면의 코드를 import 줄, sha256 도우미 메서드, main 메서드의 틀로 감싼 소스로 만들어 javax.tools로 컴파일하고 새 클래스 로더로 읽어 Main.main을 실행했습니다. 화면에는 import, main, sha256 메서드를 줄였고, 줄인 sha256 메서드는 MessageDigest로 SHA-256을 계산해 소문자 16진수 글자 64개로 바꿉니다. 대조한 것은 화면의 코드가 실행한 소스의 그 부분과 같은 글인지, 프로그램이 출력한 saved와 ok, 입력에서 계산한 값(컴파일한 Main의 sha256 메서드를 입력에 대해 불러 얻었습니다)입니다. 결과 표의 값은 저장된 값과 입력에서 계산한 값의 앞 12자와 "… (64자)"로 줄여 적었고, 줄이기 전의 값 전체도 같은 방식으로 대조했습니다. 비밀번호를 그대로 저장하는 쪽에서 계산이 일어나지 않는다는 것은 그 코드에 sha256 호출이 없는 것으로 확인했습니다. 계산이 표준대로인지는 SHA-256의 표준 예시 두 가지(메시지 "abc"와 56글자 메시지)를 자바로 계산해 문서의 값과 견주었고, "abc"의 값은 NIST의 SHA-256 예시 문서와 RFC 6234의 시험 벡터에서, 56글자 메시지의 값은 NIST의 예시 문서에서 확인했습니다. 같은 8가지 입력(빈 글자, 1글자, 100글자 포함)을 Node.js의 crypto와 이 PC의 openssl dgst -sha256으로도 계산해 세 구현의 값이 모두 같은 것을 확인했습니다. 단계 문장이 기대는 사실도 따로 재었습니다. 같은 입력을 두 번 해시하면 같은 값이고, pass1234와 pass1235의 해시는 64자 가운데 61자가 다르며(이 입력에서 관찰한 값입니다), 입력이 0글자, 1글자, 100글자여도 결과는 64자입니다. 문서는 직접 열어 확인했습니다. NIST FIPS 180-4(Secure Hash Standard)는 1절에서 SHA-256 등을 반복 계산하는 단방향 해시 함수로, 입력(메시지)에서 메시지 다이제스트라는 압축된 값을 만든다고 하고 메시지가 조금이라도 바뀌면 매우 높은 확률로 다른 다이제스트가 나온다고 하며, 1절의 표는 SHA-256의 다이제스트 크기를 256비트(16진수 64자)로 정했고, 문서 앞머리의 설명은 주어진 다이제스트에 맞는 메시지를 찾는 것과 같은 다이제스트를 내는 두 메시지를 찾는 것이 계산상 불가능하다는 뜻으로 "안전한" 해시 알고리즘이라 부른다고 말합니다. OWASP의 Password Storage Cheat Sheet는 비밀번호를 평문으로 저장하지 말고 해시해서 저장하라고 하고, 해시는 한 방향 함수라서 원래 비밀번호로 되돌릴 수 없다고 하며(암호화가 아니라 해시), 약하고 흔한 비밀번호는 흔한 비밀번호 목록이나 모든 후보를 하나씩 넣어 보는 방법으로 찾아질 수 있다고 합니다. 이 실습에서 "거꾸로 계산해 낼 수 없다"는 것은 수학적으로 증명된 것이 아니라 현실적으로 알려진 방법이 없다는 뜻이고, 짧고 흔한 비밀번호는 후보를 하나씩 해시해서 저장된 값과 견주는 방법으로 찾아질 수 있습니다. 이 실습은 해시의 성질을 보이려고 빠른 범용 해시 함수 SHA-256을 그대로 썼습니다. 실제 서비스에서 비밀번호를 저장할 때는 SHA-256을 그대로 쓰지 않고, 일부러 느리게 만들고 사용자마다 다른 값(솔트)을 섞는 비밀번호 전용 함수(BCrypt, Argon2 등)를 씁니다. OWASP 문서도 SHA-256 같은 빠른 해시 알고리즘은 비밀번호 저장에 맞지 않다고 하고, Argon2id를 먼저 권하고, 그것을 쓸 수 없을 때의 scrypt, 오래된 시스템의 bcrypt, FIPS 인증이 필요할 때의 PBKDF2를 들며, 사용자마다 고유한 솔트를 더하라고 합니다. 그 이유와 동작은 "세부 동작 실습: 비밀번호 해시"에서 다룹니다. 화면의 코드는 가입과 로그인을 한 번의 실행 안에 이어 적은 것입니다. 실제 서비스에서는 가입과 로그인이 서로 다른 요청이고 saved는 DB에 보관되며, 서버가 사용자가 입력한 비밀번호 글자를 받아 해시한다고 가정했습니다. 입력을 사용자의 기기에서 미리 해시하는 방식, 해시 값을 직접 비교할 때의 시간차 공격, 솔트와 느린 해시, 해시 충돌은 다루지 않았습니다. 이 실습의 해시 함수는 암호용 해시 함수이고, HashMap의 해시 코드와는 쓰임이 다릅니다.

앞 실습: 토큰 · 다음 실습: 암호화

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요