Python 3.3 부터는 내장 hash 함수가 RANDOM SEED를 이용하는 방식으로 바뀌었고, 내부 함수도 내부적으로 SipHash 를 쓰도록 바뀌었고, Redis 에서도 4.x 부터는 내부 해쉬 방식이 SipHash 를 쓰는 것으로 바뀌었습니다.
그러면 잘 쓰고 있던(?) 기존의 hash를 왜 siphash라는 구조로 바꾸는 것일가요? 아, 일단 먼저 고백할 것은 전 siphash 가 뭔지는 잘 모릅니다. siphash 홈페이지를 방문하면 다음과 같은 내용을 볼 수 있습니다.
SipHash is a family of pseudorandom functions (a.k.a. keyed hash functions) optimized for speed on short messages.
Target applications include network traffic authentication and defense against hash-flooding DoS attacks.
SipHash is secure, fast, and simple (for real):
SipHash is simpler and faster than previous cryptographic algorithms (e.g. MACs based on universal hashing)
SipHash is competitive in performance with insecure non-cryptographic algorithms (e.g. MurmurHash)
We propose that hash tables switch to SipHash as a hash function. Users of SipHash already include FreeBSD, OpenDNS, Perl 5, Ruby, or Rust.The original SipHash returns 64-bit strings. A version returning 128-bit strings was later created, based on demand from users.
Intellectual property: We aren’t aware of any patents or patent applications relevant to SipHash, and we aren’t planning to apply for any. The reference code of SipHash is released under CC0 license, a public domain-like license.
뭔지 잘 모르겠지만, 자애로운 구글신을 영접하면 다음과 같이 번역되어 나옵니다.(아휴… 이제 번역은 구글느님이시죠.)
SipHash는 단문 메시지의 속도에 최적화 된 의사 난수 함수 (a.k.a. 키 해시 함수)의 계열입니다.
대상 응용 프로그램에는 네트워크 트래픽 인증 및 해시 넘침 DoS 공격 방어가 포함됩니다.
SipHash는 안전하고 빠르며 간단합니다 (실제).
SipHash는 이전의 암호화 알고리즘 (예 : 범용 해시 기반의 MAC)보다 간단하고 빠릅니다.
SipHash는 안전하지 않은 비 암호화 알고리즘 (예 : MurmurHash)
우리는 해시 테이블을 해시 함수로 SipHash로 전환 할 것을 제안합니다. SipHash 사용자는 이미 FreeBSD, OpenDNS, Perl 5, Ruby 또는 Rust를 포함합니다.원래 SipHash는 64 비트 문자열을 반환합니다. 128 비트 문자열을 반환하는 버전이 나중에 사용자의 요구에 따라 만들어졌습니다.
지적 재산권 : 우리는 SipHash와 관련된 특허 또는 특허 출원을 모르고 있으며, 신청할 계획이 없습니다. SipHash의 참조 코드는 공개 도메인과 같은 라이센스 인 CC0 라이센스에 따라 릴리스됩니다.
그럼 왜 이런걸 사용하는가라고 한다면, 다음과 같은 예를 하나 들어보려고 합니다. Java 7이나 .NET의 Set 자료구조를 보면, 실제로 내부에는 Hash 자료구조를 사용하고 있습니다.(당연하지!!! 그것도 몰랐냐 이넘아 하시면… 전… 굽신굽신) Set이라는 자료구조는 특정 item 이 존재하는지 아닌지를 상수시간(이라고 적고 빠른 시간에) 확인할 수 있는 자료구조입니다. 그런데 Hash는 보통 충돌이라고 불리는 Hash 값이 겹칠 수 밖에 없는 경우가 존재하고 이를 막기 위해서, 링크드 리스트를 이용한 이중 체인 같은 것을 많이 사용합니다.
즉 다음 그림과 같은 구조가 됩니다. 일단 다음 그림은 아주 이상적으로 Hash 가 하나씩 차지한 경우이구요.
다음은 이제 삐꾸가 나서 한 hash slot 에만 비정상적으로 몰리는 경우입니다.
그런데 지금 이러한 내용을 왜 말하는가 하면, 이런 특성을 이용해서 특정 서비스에 DOS(Denial of Service) 서비스를 할 수 있다는 것입니다.(아니 자네, 지금 무슨 말을 하는 것인가?)
우리가 흔히 아는 DOS 또는 DDOS는 무수한 클라이언트를 이용해서, 특정 사이트에 접속을 시도하거나 해서, 서비스를 못할 정도로 네트웍을 사용하거나, 특정 서비스에 시간이 오래걸리는 무거운 작업을 하도록 하여서, 서비스를 하지 못하게 하는 공격입니다.
자, 여기서 힌트를 얻으신 분들이 있으실지도 모르겠습니다. 앞에서 말한 Hash를 바꾼 이유와, 시간이 오래걸리는 무거운 작업을 하도록 한다를 섞으면… 설마라고 생각하시는 분들이 계실텐데… 넵 바로 그 이유입니다.(전 사실 그 이유를 모르죠!!! 퍽퍽퍽…)
자, 만약에 특정 사이트에서 어떤 컴포넌트를 이용하는 걸 알고 있습니다. 그리고 그 툴에서 자료구조를 어떻게 처리하는 지도 안다면? 예를 들어, Java 7의 Set 이나 HashMap 을 사용하는 걸 알고, 거기에 데이터를 넣을 것이라는 걸 안다면…(오픈소스들이 위험할 수 있습니다.) 특정 패턴의 Key를 넣는 것으로, Hash 검색 속도를 미친듯이 느리게 만들 수 있습니다.
한 곳에 데이터가 몰리는 것을 skew 라고 하고 이진 트리등에서도 skew 가 되면 엄청 느린 검색 속도를 보여주게 되는데.. 위의 그림 처럼, 충돌이 일어날 경우에 linked list를 이용한 체이닝으로 문제를 푼다면, 거기에만, 천개, 만개, 십만개가 있다면, 이제 해당 슬롯에 있는 key를 조회하는 명령이 들어오면, 속도는 점점 느려지게 될 것입니다.(정말?)
먼저 java7 에서의 HashMap에서 hash 함수를 확인해보도록 하겠습니다.(왜 자바7이냐 하면 자바8에서는 HashMap이 충돌시에 Tree 형태로 저장되게 됩니다. – 그러나 이것도 효율적이긴 하지만, 정말 많은 데이터를 한 곳에 넣으면… 문제의 소지가!!!)
java7 에서의 HashMap 에서 사용하는 hash 함수는 다음과 같습니다.
static int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
hash(key.hashCode()) 이런식으로 이용하게 되는데 만약에 key가 string class의 경우 hashCode() 함수는 다음과 같습니다.
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
즉 특정 string 의 hash table의 위치는 항상 그대로 입니다. 이걸 이용해서 같은 해쉬 슬롯에 들어가는 데이터를 대량으로 던져준다면?(사실 이게 쉽지는 않습니다., 어떤 key를 추가하게 할 것인가라는게 사용자 입장에서는 접근할 여지가 적은… 하지만… 가능하다면?) 물론 java7에서도 아이템 개수가 커지면 Table을 키우게 되긴 합니다만… 이것 역시 동일한게 만들어주면… 사실 더 최악인 메모리는 계속 팩터 만큼 커지는데… 아이템은 계속 같은 곳에 몰리는 형상이 벌어질 수 있습니다.(이걸 의도한 공격이죠.)
실제로 java7 에서는 resize(), transfer() 함수가 테이블 확장을 하게됩니다.
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } Entry[] newTable = new Entry[newCapacity]; transfer(newTable); table = newTable; threshold = (int)(newCapacity * loadFactor); } void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { Entry<K,V> e = src[j]; if (e != null) { src[j] = null; do { Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null); } } }
그럼 siphash를 쓰면 어떤 부분에서 도움이 될까요? 위에서 보여준 java7에서의 hash 나 python의 기존방식이나, redis 3.x대의 hash 또는 MD5, SHA1, SHA256 계열의 경우, 우리가 알듯이 항상 특정 key 의 hash는 같은 알고리즘에서 항상 같은 결과가 나옵니다.(consistent hashing 같은 경우는 이런 특성을 이용한 방식이죠.)
그런데 siphash 종류의 hash는 seedkey라는 것을 hash 시에 추가로 받습니다. 그리고 이 seedkey로 인해 hash 결과가 바뀌게 됩니다. 그럼 이걸 어떻게 해야하는가? 예를 들어 프로세스가 시작되는 타이밍에 저 seedkey를 랜덤으로 생성합니다. 그러면, 해당 프로세스 내에서는 항상 동일한 값을 보장하지만, 새로운 프로세스가 뜨면, 동일한 key에 다른 hash값을 내놓을 것입니다.(물론 해당 프로세스 내에서는 항상 동일하겠죠.) 즉 어떤 서버에서 실행될 때, 이 key가 어떤 위치에 위치할지를 알 수 없게 됩니다. 그로 인해서 위의 알고리즘을 알더라도, 같은 슬롯에 충돌을 유도하기가 힘들어집니다. 다음은 Redis에서 패치된 코드입니다. 기본 hash가 내부적으로 siphash를 쓰도록 되어 있습니다.
int main(int argc, char **argv) { ...... getRandomHexChars(hashseed,sizeof(hashseed)); dictSetHashFunctionSeed((uint8_t*)hashseed); ...... } uint64_t dictGenHashFunction(const void *key, int len) { return siphash(key,len,dict_hash_function_seed); }
비슷한 이유로 Python 에서도 3.3 이후로는 기본으로 RANDOM SEED와 siphash류를 쓰도록 되어있습니다.