패스워드를 안전하게 저장하는 것은 인터넷 서비스의 기본이지만 과거 국내 서비스에서는 잘 지켜지지 않았던 부분이다. 지금은 개발자들 사이에 패스워드 보안의 중요성에 대한 인식이 퍼져서 비교적 상황이 나아졌으나, 아직도 제대로 하지 않는 사이트가 적지 않고, 심지어 패스워드 입력 인터페이스만 봐도 문제가 있는 것으로 추정되는 경우가 많다. 하지만 이미 패스워드를 안전하게 저장하는 방법은 정답이 나와 있는 상태이므로, 본 연구에서는 정답에 대해서는 간단하게 소개하고, 왜 그런 정답이 도출되었는지에 대해 집중적으로 파고 들 것이다. 즉, 이 글은 실용적인 목적보다도 패스워드 보안 기술에 대한 개발자의 호기심을 충족시켜주는데 초점을 맞출 것이다.
정답부터 간단히 이야기하면 pbkdf2, bcrypt, scrypt 중에 하나를 써서 패스워드를 암호화하면 된다. 다행스러운 것은 수년 전 md5 파동 이후 패스워드 보안에 대한 개발자들의 관심이 높아져서 데이터베이스에 md5로 바로 패스워드를 저장하는 개발자들은 많아 사라졌고, 정답을 쓰는 곳도 많아졌다. Django 같은 프레임웍에서는 기본으로 강력한 패스워드 저장 방식을 채택하고 있기 때문에 본의 아니게 안전하게 저장하고 있는 개발자도 많다. 실용적인 관점에서 이 글을 읽는 독자는 올바른 패스워드 저장 방식으로 바로 점프해서 읽으면 된다.
다음의 주제들은 실제로 필자가 과거에 패스워드 보안을 다루면서 느꼈던 의문, 혹은 다른 개발자에게 질문을 받았던 것이다.
- 패스워드를 해싱해야 하는 이유
- 왜 양방향 암호화는 안되는가
- md5를 비롯한 단방향 해싱의 크랙 방법
- salt는 SHA 256 같은 강력한 해시 알고리즘을 쓰더라도 필요한가?
- 권장할 만한 패스워드 저장 방식
이 글에서는 위의 의문에 답하고자 한다.
패스워드를 암호화해야 하는 이유
패스워드를 평문으로 저장하면 왜 안되는가? 알기 쉽게 설명한 패스워드 암호화에 잘 설명되어 있는데, 질문에 대한 답만 간단히 요약한다면 서버의 데이터베이스가 털렸을 때도 회원의 패스워드를 알 수 없게 만들기 위한 것이다. 패스워드가 잘 해싱되어 있으면 해커가 그 해시 값을 보더라도 사용자의 계정으로 로그인할 수 없기 때문에 해커에게 의미 없는 정보가 된다.
데이터베이스를 잘 지키면 되지 않나?
그럼 여기서 의문이 하나 생긴다. 서버를 안 뚫리게 하면 되지 않나? 패스워드를 안전하게 저장하려는 노력을 기울이는 대신, 서버 보안을 신경 쓰는 게 더 좋지 않을까? 원천봉쇄가 더 좋은 것은 당연하다. 하지만, 문제는 그 원천봉쇄가 쉽지 않다는 것이다. 서버는 보안 위험 요소가 너무 많아서 완벽하게 지키는 것이 어렵다. 게다가, 실제로 서버가 해킹 당하지 않아도 데이터베이스는 털릴 수 있다. 개발자가 데이터베이스를 백업한 덤프 파일을 옮기는 과정에서 실수로 SSL이 아닌 통신을 사용한다거나, 메일 따위로 주고 받는다거나, 로컬로 내려받아놓고 자리를 비운 사이 PC에 다른 사람이 접근한다거나 등등. 물론, 이런 초보적인 수준의 실수는 충분히 통제할 수 있다고 생각할 수 있을지 모른다. 그런데, 만일 개발자가 악의적인 의도를 가지고 패스워드를 본다면? 자기가 개발하는 사이트에 옛날 여자친구가 가입했다는 사실을 알고 그 여자친구의 패스워드를 알아낸 다음 그걸로 페이스북에 로그인을 시도한다면? 개발자들의 도덕성이 충분하다고 한들, 그런 가능성이 열려 있는 웹사이트에 가입하고 싶겠는가. 그래서, 원천봉쇄보다는, 데이터가 털리더라도 패스워드가 유출되지 않게 저장할 필요가 있는 것이다.
양방향 암호화
md5, sha1 등의 해싱 알고리즘이나 AES 같은 양방향 암호화를 둘다 묶어서 암호화라고 부르기도 하고, 해싱은 암호화가 아니고 양방향 암호화만 암호화라고 하기도 하는데, 암호화의 목적이 반드시 복호화인 것은 아니므로 굳이 해싱을 암호화가 아니라고 할 필요는 없다. 영어권에서도 해싱과 관련해서 encryption, cryptocraphic hash function 등의 표현을 쓴다. 아무튼, 패스워드를 암호화해야 한다면 AES처럼 복호화가 가능한 방식으로 할 수도 있을 텐데, 왜 굳이 복화하가 불가능한 단방향 암호화를 하는 것일까?
그 이유는 패스워드를 평문으로 저장하지 않는 이유와 같다. 양방향 암호화는 알고리즘과 키값만 노출되면 바로 복호화가 가능하다. 서버가 해킹 당한 상황이면 소스코드도 다 노출된 상황이므로 알고리즘과 키값 모두 노출된 상황이나 마찬가지다. 그리고 역시 위에서 언급한 것과 마찬가지로 내부 개발자는 이미 알고리즘과 키, 데이터베이스에 모두 접근이 가능하므로 원하면 언제든 사용자의 패스워드를 볼 수 있다. 그래서, 패스워드 보안에서 양방향 암호화는 별 의미가 없다. 평문으로 저장한 것과 별반 다르지 않은 보안 수준이라고 할 수 있다.
원래 양방향 암호화는 데이터 보관의 보안보다는 통신 상황에서의 보안에 더 유용한 암호화다. 데이터 보관의 보안도 의미가 있으려면 키값을 보안을 유지하고 싶어하는 사람만 갖고 있어야 하므로 서버의 키값으로 양방향 암호화를 하는 것은 데이터 보관의 보안에서는 별 의미가 없다. 최근 이슈가 되었던 카톡의 보안 문제도 간단치 않은 게, 단순히 서버의 키값으로 암호화를 하면 검찰이 데이터를 요구할 때 복호화해줄 수 있으므로 의미가 없다. 그래서 클라이언트끼리 키값을 주고 받고 그 키값으로 암호화해서 통신해야 하기 때문에 생각처럼 단순한 문제는 아니다.
해시의 크랙
해시를 어떻게 크랙하는지 알아보기 전에 만약 해시(hash)가 뭔지 잘 모른다면 일단 해시값의 복호화를 한 번 읽어보도록 하자.
Brute force attack
brute force는 그 단어의 느낌처럼 무식하게 조합 가능한 모든 패스워드를 대입해보는 것이다. 대입해서 똑같은 해시값이 나오면 빙고. 기본적으로 모든 해시는 brute force로 뚫을 수 있다. 다만, 그 시간이 엄청나게 오래 걸리게 만들어서 뚫기 어렵게 만들 수 있을 뿐이다. md5가 보안 목적으로 적합하지 않은 것은 일단 기본적인 brute force로도 꽤 빠른 시간에 뚫린다는 것이다. New 25 GPU Monster Devours Passwords In Seconds에 따르면 md5의 경우 25개의 GPU로 초당 1800억개의 대입이 가능하다. 단순히 영문 대소문자, 숫자만 조합한 8자리 고정 패스워드라면 62^8개의 조합이 가능한데, 이 정도면 62^8 / 1800억 = 1213초, 불과 20분이면 다 크랙되는 것이다. 그래도 10자 이상이면 두어 달 걸리니까 조금 더 안전하고, 특수문자도 조합하면 더 안전해지지만, 어쨋든 단순 무식 공격으로도 꽤나 위험한 것이다. 예전에는 그냥 CPU를 이용했었는데, 수년 전부터 수치 연산에 강력한 GPU를 이용하면서 brute force 공격이 훨씬 강력해졌다.
Rainbow table
그럼 만약에 해시 알고리즘을 엄청나게 복잡하게 만들어서 오래 걸리게 하면 brute force 공격으로부터 안전해지지 않을까? 이를테면 SHA-512의 경우는 앞서의 공격으로도 초당 364000개 밖에 대입하지 못한다고 한다. 물론 이것도 엄청나지만 앞서의 8자리 영문자 숫자 조합의 경우 7만년 쯤 걸린다. 이 정도면 안전하지 않을까?
그렇지 않다. Brute force attack을 가만히 생각해보면 더 좋은 방법을 생각해낼 수 있다. 미리 가능한 패스워드 조합을 다 계산한 테이블을 가지고 비교만 수행하는 것이다. 이것이 dictionary attack인데, 이 dictionary를 해시값 검색에 최적화시킨 것을 rainbow table이라고 한다. md5의 경우는 인터넷에 이미 수백억 개의 해시값에 대한 rainbow table이 있다. 이미 계산된 값을 이용하므로 알고리즘의 복잡도도 큰 상관이 없다. 이 안에 있는 패스워드는 그냥 금방 뚫리는 것이다. 공개된 md5 rainbow table로 찾으면 대부분의 웹사이트에서 90% 정도의 사용자 패스워드가 크랙된다고 하니 이쯤되면 md5는 이미 뚫려 있다.
물론 rainbow table을 만드는데도 시간이 많이 들기 때문에 알고리즘의 복잡도가 전혀 상관이 없는 것은 아니다. 알고리즘 수행시간이 길면 충분한 규모의 rainbow table을 확보하기도 어렵다. 그래도 쉬운 패스워드는 뚫리니까 보안성을 크게 높일 수 있다고 말하긴 어렵다.
Collision attack
하지만 사실 md5가 broken 판정을 받은 것은 brute force 때문이 아니다. md5는 1996년에 이미 collision 취약점에 대한 이론적인 가능성이 제시되었고, 이후에 그게 현실로 나타났다. collision은 서로 다른 두 원본 메세지가 같은 해시값을 갖는 경우를 말한다. 해시 함수의 특성상 collision은 존재할 수 밖에 없는데, 이런 collision을 찾기 힘든 특성을 collision resistance라고 하며 이것은 암호화 목적의 해시에 필수 요소다. 그런데, md5는 그 collision resistance가 낮아서 collision을 쉽게 찾을 수 있는 것이다. 위의 링크에서 그 collision의 예를 볼 수 있다.
그런데, 여기에 또 반전이 있다. md5가 collision attack에 취약해서 암호화 용도 폐기 판정을 받았지만, 사실 패스워드 암호화는 collision attack과 상관이 없다. collision attack으로 같은 해시값을 갖는 두 개의 데이터를 만들어내기는 쉽지만, 단순히 해시값이 주어졌을 때 그 해시값에 맞는 collision들을 찾아낼 수 있는 것은 아니다. 그래서, collision attack은 패스워드 크랙이 아니라 인증서 위조, 악성코드가 담긴 실행파일 만들기 등에 사용된다.
다만, 시기적으로는 md5가 암호화 용도 폐기 판정을 받은 시점과 GPU를 사용한 알고리즘으로 brute force가 매우 강력해진 시점이 어느 정도 겹친다. 어쨋든 md5는 패스워드든 아니든 암호화 용도로는 사용할 수 없는 상황이 되었다.
Preimage attack
해시 알고리즘을 패스워드 암호화에 쓸 수 있는지 판단하는 기준은 preimage attack인데, 이것은 해시값에서부터 원본 데이터를 찾아낼 수 있는 가능성이다. 이건 md5를 포함한 대부분의 해시 함수가 안전하기 때문에 크게 걱정할 필요는 없으나, 암호학을 모르는 사람이 직접 만든 해시 함수를 사용하지 말아야 하는 이유가 되긴 한다.
패스워드 크랙 방어
collision attack과 preimage attack은 별로 걱정할 필요가 없다면 패스워드 보안에서 중요한 건 brute force와 rainbow table에 대한 방어다. 그래서 이에 대한 대처는 두 가지로 요약된다.
- 암호화 시간을 많이 걸리게 만들어서 brute force 공격의 효율성을 떨어뜨린다.
- salt를 이용해서 rainbow table을 무의미하게 만든다.
Salt
해시에 대한 다양한 크랙 수단이 있지만, 그 중 가장 효율적인 크랙 수단은 물론 rainbow table이다. 그런데, 이 rainbow table은 또한 아주 쉽게 무력화할 수 있는 방법이기도 하다. 단순히 동일한 salt값을 추가하는 것만으로도 미리 계산해놓은 rainbow table은 꽤 힘이 빠진다. 예를 들어 패스워드를 codeok라고 정했는데 단순히 md5로만 해싱했다면 다음과 같은 값이 나온다.
8587229bbf6f4dc5efa906f04291519f
위의 해시값이 rainbow table에 들어 있다면 이 패스워드의 계정은 바로 크랙된다. 물론, rainbow table에 위의 해시값으로 매핑된 원본 텍스트가 codeok는 아닐 수 있다. collision이 있기 때문이다. 하지만 패스워드 체크는 같은 값만 나오면 되기 때문에 통과할 수 있다.
그런데, 만일 패스워드를 암호화할 때 salt로 .net을 붙여서 codeok.net을 md5로 해싱했다고 해보자. 그러면 다음과 같은 값이 나온다.
25f43a96006d7a7b1434384e8acc6f5e
이 값으로 rainbow table을 검색했을 때 나온 원본 텍스트를 비밀번호 입력창에 넣는다면 어떻게 될까? 그러면 그 원본 텍스트에 또 .net을 붙여서 암호화한 다음 비교하기 때문에 실패하게 된다. 설령 원본 텍스트로 codeok.net이 나왔다고 해도 소용 없다. 그래서 단순한 고정값 salt로도 rainbow table의 힘을 떨어뜨릴 수 있다.
그러나, 고정값 salt만 있으면 rainbow table에서 salt까지 포함한 테이블을 만들 수도 있다. 예를 들어서 salt 길이가 1자라고 한다면, rainbow table을 만들 때 패스워드 허용 문자 개수를 곱한 만큼 만들면 된다. 수십 배 커지지만 상대적으로 엄청난 rainbow table의 사이즈에 비하면 크게 늘어난다고 하기는 어렵다. 그래서 salt는 최소 128 bit 정도는 되어야 안전하다고 한다.
근데 이게 끝이 아니다. 설령 충분히 긴 salt를 주더라도 rainbow table에 당할 수 있다. 많은 수의 패스워드를 rainbow table로 찾아서 원본 텍스트를 나열해보면 공통적인 부분이 발견될 수 있기 때문이다. 만일 위의 두번째 해시값에서 원본 텍스트로 codeok.net이 나왔고, 또 다른 해시값에서 newsqu.net이 나왔다면 .net이 salt라고 추정할 수 있다. 그래서 salt 값도 고정값을 쓰면 안되고 암호학적으로 안전한 랜덤 함수를 이용하는 것이 좋다.
salt의 목적이 rainbow table의 무력화이므로 salt 값은 그냥 패스워드 해시값과 같이 저장해도 상관 없다. Django의 경우 패스워드 해시와 salt를 한 필드에 같이 저장한다. 왠지 같이 저장하면 해싱을 reproduce할 수 있기 때문에 안전하지 않을 것 같은 느낌이 들지만, 어차피 패스워드 암호화는 reproduce해야 하고, salt는 rainbow table만 막으면 된다.
알고리즘 수행 시간
salt로 rainbow table을 막았다면 이제 남은 것은 brute force 공격이다. 반복하지만, brute force는 원천적으로 막을 방법은 없고, 암호화 알고리즘을 느리게 만들어서 brute force 공격의 효율을 떨어뜨리는 방법 뿐이다. 그렇다면 엄청나게 복잡하게 느린 알고리즘을 사용하면 되겠네. 땡!
더 좋은 답은 알고리즘 수행 시간을 조정 가능한 방법을 쓰는 것이다. 컴퓨팅 파워는 계속 향상되고, 필요한 보안 수준은 사이트에 따라, 사이트의 성장에 따라 달라진다. 알고리즘 수행 시간을 조정하는 가장 쉬운 방법은 해싱을 반복하는 것이다. 간혹 md5로 암호화를 해놓고, 좀더 보안 수준을 높여보겠다면서 sha1(md5(password))
같은 식으로 하는 경우가 있다. 이것도 의도는 좋으나, 해싱을 두 번 반복하는 것 뿐이므로 brute force 공격 앞에서는 별다른 의미가 없다. PBKDF2 같은 경우는 반복회수를 조정할 수 있는데, 권장하는 최소 반복회수가 1000번이다.
올바른 패스워드 저장 방식
패스워드 크랙을 대비하기 위해 salt도 써야 하고, 해싱을 반복까지 해야 하다니 할일이 제법 많다. 하지만 이걸 직접 짜라는 게 아니다. 귀찮은 일이기도 하거니와, Home grown crypto is bad crypto이기 때문이다. 위의 내용을 다 포괄하고 있는 암호화 알고리즘들이 이미 옛날옛적에 개발되어 있다. 다음 세 가지 정도를 추천할 만하다.
추천 알고리즘
Django에서는 기본으로 PBKDF2를 사용하고 있다. bcrypt는 조금 더 안전한 것으로 평가되나 입력값을 72 byte로 해야 해서 조금 귀찮다. scrypt는 상대적으로 최신 알고리즘이며 나머지 둘보다 더 경쟁력 있는 것으로 평가되나, 아직 덜 확산되어 있다. 어쨋든 이 셋 중에 아무 거나 골라도 괜찮다.
그리고 공모를 통해 AES 같은 알고리즘을 발굴한 사례를 본따 새로운 패스워드 암호화 알고리즘을 찾기 위해 Password Hashing Competition을 열고 있으니 암호학 전문가라면 참가해볼 만할 듯.
사용자 인터페이스의 고려사항
패스워드를 저장만 잘한다고 되는 게 아니다. 서두에서 인터페이스만 보고도 보안 문제가 의심되는 경우가 있다고 했는데, 지켜야 할 것이 몇 가지가 있다. 우선 패스워드의 개수를 제한하는 것은 되도록 삼가해야 한다. 특히 아주 작은 값, 예를 들어 10이나 12 정도로 패스워드를 제한하고 있으면 이놈들 패스워드를 평문으로 저장하고 있는 것 아닌가? 하는 의심이 든다. 실제로 과거의 프로그래머들은 데이터베이스의 컬럼 길이를 되도록 작게 잡으려는 경향이 있었고, 그 컬럼 크기에 맞춰서 패스워드 길이를 제한했었기 때문이다. 그런데 패스워드를 제대로 해싱한다면 패스워드가 아무리 길어도 해싱하면 거의 고정 길이의 텍스트가 나오기 때문에 저장에 문제가 없다. 그래서 패스워드 길이 제한을 할 필요가 없는데도 작은 값으로 제한하고 있다면 평문 저장이 아닌지 의심하게 되는 것이다.
물론 최소 길이는 제한하는 것이 좋다. 짧은 패스워드는 brute force로 빨리 잡힐 수 있기 때문이다.
길이 제한이 나쁜 것은 평문 저장이 의심되기 때문만은 아니다. 길이 제한을 알게 되면 brute force의 범위를 좁힐 수 있기 때문이다. 앞서 md5의 사례에서 보듯, 비밀번호가 8자 정도로 제한되면 아주 빠른 시간에 뚫릴 수 있다. 물론 pbkdf2나 bcrypt를 사용한다면 훨씬 안전하지만, 그래도 되도록 길이를 얼마로 제한하고 있는지 모르게 하는 것이 좋다.
특수문자를 입력하지 못하게 하거나, 대소문자를 제한하는 것도 마찬가지다. 특수문자와 대소문자의 구분은 패스워드 조합 가능성을 비약적으로 높여주기 때문에 brute force 공격이 더 힘들어진다. 그런데 만일 영문소문자와 숫자만 허용하도록 인터페이스가 되어 있다면 설령 제대로 된 패스워드 암호화를 하더라도 brute force로 시도해야 할 조합이 대폭 줄어들게 된다. 이런 사이트는 가입하지 않는 게 좋다.
또 하나 많은 개발자들이 놓치고 있는 것인데, 패스워드를 입력하는 POST 요청은 반드시 SSL로 보내야 한다. password 타입의 input을 쓰는 것은 POST 요청의 보안에 아무런 역할을 하지 않는다. 패스워드 뿐 아니라 전화번호, 카드 번호 등의 민감한 정보를 보낼 때는 반드시 SSL을 사용해야 한다.
아, 참고로 패스워드 입력 input을 그냥 text 타입으로 받는 경우도 봤다. 모 카쉐어링 사이트였는데, 덕분에 가입하려다가 말았다. 이건 너무 심하지 않나.
놓치기 쉬운 것 중 하나가 패스워드 실패 회수 제한이다. 사실 국내 웹사이트는 지나치게 제한이 강한 경우가 많아서 문제이기도 하지만, 실패 회수 제한은 꼭 필요하다. 만일 실패 회수 제한이 없으면 HTTP 요청을 통해서 무제한으로 시도할 수 있게 된다. 그러면 DB가 뚫리지 않았는데도 brute force 공격이 가능해지는 것이다. 해싱을 brute force로 뚫을 수 있다고 해도 그건 해시값이 확보되었을 때 이야기고 brute force 공격 자체를 못하게 하는 것이 더 좋은 것은 물론이다. 근데, 패스워드 실패 회수를 제한하지 않으면 데이터베이스를 털리지 않고도 brute force를 허용하는 셈이 된다.
참고로 이 내용은 웹이 아니라 앱에서 API로 통신할 때도 공통적으로 적용되는 사항이다.
요약
장황한 이야기지만 요약하면 간단하다. 패스워드 암호화는 데이터베이스가 털릴 경우를 대비한 것이며, brute force 공격에 대비하기 위해 패스워드 암호화 알고리즘의 수행 시간을 조절할 수 있어야 하고, rainbow table에 방어하기 위해 랜덤 salt를 추가해야 한다는 것이다. 그리고 이런 조건을 갖춘 패스워드 암호화 알고리즘으로 pbkdf2, brcypt, scrypt 등이 있으므로 이런 건 그냥 쓰면 된다.
DISCLAIMER: 필자는 보안 전문가도 아니고 암호학에 조예가 있는 것도 아니므로 잘못된 내용이 있을 수 있음.