글을 작성하게 된 계기
회사에서 AES 알고리즘을 사용하던 중, CBC 모드도 IV 값에 따라 동일한 값이 나올 수 있다는 사실을 알게 되었고, 이를 정리하기 위해 글을 작성하게 되었습니다.
1. 왜 고정된 IV 값을 사용하려 했을까?
회사에서 클라이언트 개발자에게 AES 알고리즘의 CBC 모드 에서 고정 IV 값을 사용해달라는 요청 을 받았습니다. 처음에는 AES의 CBC 모드를 잘 몰라서 검토해 보겠다고 한 후, 주말에 학습했는데요, 이 과정에서 IV 값이 고정되면 암호화된 데이터가 항상 동일하게 생성되어 패턴 노출 이 발생할 수 있다는 문제를 알게 되었습니다.
1
2
3
4
5
6
7
8
평문: HelloWorld123456
고정 IV: PASSWORD_256_
암호문1: AABBCCDDEEFF0011
# 같은 평문을 암호화하면 같은 결과가 나오게 된다.
평문: HelloWorld123456
고정 IV: PASSWORD_256_
암호문2: AABBCCDDEEFF0011 (같음)
이를 알고 나서 클라이언트 개발자에게 이 문제를 설명했고, 고정된 IV 값을 사용하지 말아 달라고 요청드렸습니다. 다행이 잘 받아들여져서 랜덤한 IV를 생성해 사용하기로 의사결정이 됐습니다. 🚀
2. IV 값이 고정되면 왜 패턴 노출이 발생할까?
AES의 CBC 모드에서는 첫 번째 블록을 암호화할 때 IV를 평문과 XOR 해 사용합니다. 이때 IV가 고정되면, 같은 평문을 암호화할 때 항상 같은 암호문이 생성 됩니다.
- 평문이 같으면 첫 번째 블록 결과가 항상 같고,
- 이후 블록들도 첫 번째 암호문 블록(C₀)에 연쇄적으로 의존하므로 전체 암호문이 반복됩니다.
- 이로 인해 암호문 간 패턴이 노출되어, 공격자가 내용을 유추할 수 있는 단서가 됩니다.
즉, IV 값이 고정되면 첫 번째 평문 블록은 항상 같은 방식으로 암호화 되고, 이후 블록들도 이전 암호문 블록과 XOR 되어 암호화되기 때문에, 연쇄적으로 암호문이 결정되는 구조가 됩니다. 이에 따라 같은 평문은 항상 같은 암호문을 만들어내며, 결과적으로 암호문의 패턴이 반복 되고 패턴이 노출 될 위험이 있습니다.
1
2
3
4
5
6
7
8
# 고정 IV 사용 시 평문이 같으면 C₀, C₁, C₂ ... 도 항상 같아짐
[고정 IV]
↓
P₀ ⊕ IV → C₀
P₁ ⊕ C₀ → C₁
P₂ ⊕ C₁ → C₂
... ...
이를 확인해 보면 다음과 같이, 여러 번 암호화해도 암호문이 항상 동일하게 생성되는 것을 확인할 수 있습니다. 즉, CBC 모드를 사용해도 IV 값이 고정되면 패턴이 노출되는 문제가 발생할 수 있죠.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class AesCbcFixedIvLoopTest : FunSpec({
val keyBytes = ByteArray(16) { 0x01 }
val key = SecretKeySpec(keyBytes, "AES")
val fixedIv = ByteArray(16) { 0x00 }
val ivSpec = IvParameterSpec(fixedIv)
val plaintext = "HELLOHELLOHELLO"
fun encrypt(text: String): String {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec)
val encrypted = cipher.doFinal(text.toByteArray(Charsets.UTF_8))
return Base64.getEncoder().encodeToString(encrypted)
}
test("고정 IV 사용 시 여러 번 암호화해도 암호문은 항상 같다") {
val results = mutableSetOf<String>()
for (i in 1..10) {
val ciphertext = encrypt(plaintext)
println("[$i] $ciphertext")
results += ciphertext
}
results.size shouldBe 1
}
})
1
2
3
4
5
6
7
8
9
10
[1] ic1qpuT6tTmKeQho9iSaPg==
[2] ic1qpuT6tTmKeQho9iSaPg==
[3] ic1qpuT6tTmKeQho9iSaPg==
[4] ic1qpuT6tTmKeQho9iSaPg==
[5] ic1qpuT6tTmKeQho9iSaPg==
[6] ic1qpuT6tTmKeQho9iSaPg==
[7] ic1qpuT6tTmKeQho9iSaPg==
[8] ic1qpuT6tTmKeQho9iSaPg==
[9] ic1qpuT6tTmKeQho9iSaPg==
[10] ic1qpuT6tTmKeQho9iSaPg==
3. IV 값은 어떻게 관리해야 할까?
CBC 모드에서 패턴 노출을 막기 위해 IV는 매번 랜덤하게 생성하거나 nonce와 counter를 조합해 중복 없이 자동 생성해야 하며, 복호화를 위해 암호문 앞에 붙여 함께 전송하거나 저장하는 방식을 사용할 수 있습니다. 물론 정답은 없습니다. 😄
- IV를 매번 랜덤하게 생성하기
- Nonce와 Counter를 조합해 자동 생성하기
3-1. IV를 매번 랜덤하게 생성하기
패턴이 노출되는 것을 막기 위해서는 IV를 매번 새롭게 랜덤 생성해야 합니다. 다음과 같이요. 이렇게 하면 매번 다른 IV가 생성되어, 같은 평문을 암호화하더라도 암호문이 달라집니다. 따라서 패턴 노출 문제를 해결할 수 있습니다.
1
2
3
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
val ivSpec = IvParameterSpec(iv)
IV는 비밀 값이 아니며, CBC 복호화를 위해 필요하므로 암호화 시 암호문 앞에 붙여 전송합니다. 수신자는 이를 분리하여 복호화에 사용하는데, 이 방식은 별도의 IV 저장이나 동기화 없이 간편하고 안전하게 IV를 전달할 수 있습니다.
1
2
3
4
5
6
val iv = result.copyOfRange(0, 16)
val ciphertext = result.copyOfRange(16, result.size)
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.DECRYPT_MODE, key, ivSpec)
val decrypted = cipher.doFinal(ciphertext)
3-2. Nonce와 Counter를 조합해 자동 생성하기
이를 데이터베이스에 저장 시 암호문 + IV 를 한 덩어리로 저장하면 따로 관리할 필요 없습니다. 만약 nonce + counter 구조 와 같이 IV를 재생성할 수 있는 경우, 별도 저장 없이도 동일하게 복원할 수 있습니다. nonce는 고정된 고유한 값 이며, counter는 각 블록마다 증가하는 값 입니다. 이 경우 중복 없이 IV를 자동으로 생성할 수 있어서, 매번 랜덤으로 생성하고 저장할 필요가 없죠.
1
2
3
4
5
IV₀ = nonce || 00000001
IV₁ = nonce || 00000002
IV₂ = nonce || 00000003
......
4. 정리
CBC 모드를 사용해도 IV 값이 고정되면 패턴이 노출되는 문제가 발생합니다. 따라서 IV는 매번 랜덤하게 생성하고, 암호문과 함께 전송하여 복호화 시 사용해야 합니다.