이전 포스팅에서는 시스템 자원을 적절하게 반환하지 않았을 때 발생할 수 있는 DoS 취약점과 이를 예방하는 방법을 살펴봤습니다.

애플리케이션에서는 메모리 할당이나 데이터 처리 범위 등을 제한하기 위해 입력값과 자원 사용량을 검증합니다. 하지만 검증 과정에서 수행하는 정수 연산에 오버플로가 발생하면 계산 결과가 예상과 달라져 제한 조건을 잘못 판단할 수 있습니다.

이번 포스팅에서는 Oracle Secure Coding Guidelines for Java SE의 세 번째 DoS 보안 지침인 DOS-3: Resource limit checks should not suffer from integer overflow를 통해 정수 오버플로가 어떻게 DoS 취약점으로 이어지는지 살펴보고, 안전한 검증 방법을 알아보려고 합니다.


1. DOS-3: Resource limit checks should not suffer from integer overflow

Oracle의 세 번째 DoS 보안 지침인 DOS-3: Resource limit checks should not suffer from integer overflow는 자원 사용량을 제한하는 검증 과정에서 정수 오버플로가 발생하지 않도록 주의해야 한다는 내용을 담고 있습니다.

예를 들어 두 값의 합계가 허용된 범위를 초과하는지 검사할 때, 덧셈 결과에 정수 오버플로가 발생하면 실제로는 제한을 초과했는데도 정상적인 값으로 판단할 수 있습니다.

이처럼 자원 사용량을 제한하는 검증 로직이 존재하더라도 정수 오버플로가 발생하면 제한 조건을 우회할 수 있으며, 결과적으로 과도한 자원 소비나 서비스 거부 문제로 이어질 수 있습니다.

따라서 자원 사용량을 검증할 때는 입력값의 범위뿐 아니라 검증 과정에서 수행하는 정수 연산의 안전성도 고려해야 합니다.


2. 정수 오버플로와 자원 제한 검증의 문제

정수 오버플로는 개발자라면 알아야 할 기본적인 개념이지만, 코드를 작성하면서 의식하지 못하고 넘어갈 수 있는 부분이기도 합니다.

저는 신입 개발자로 입사한 후 약 17년 동안 금융 IC카드에 탑재되는 스마트카드 운영체제인 COS(Card Operating System)와 Java Card Virtual Machine을 개발했었습니다.

사실 서버 개발 경험보다는 임베디드 소프트웨어 개발 경험이 훨씬 많습니다. 스마트카드와 같이 자원이 제한적인 환경에서는 정수형 변수의 표현 범위와 오버플로 문제를 주의 깊게 다뤄야 했습니다. 그래서 정수 오버플로는 지금도 비교적 익숙한 개념입니다.

2.1. 정수 오버플로가 발생하는 이유

Java의 정수형 변수는 데이터 타입에 따라 표현할 수 있는 값의 범위가 정해져 있습니다.

예를 들어 int는 32비트, long은 64비트 부호 있는 정수형으로 각각 표현할 수 있는 최댓값과 최솟값이 존재합니다. 이러한 범위를 벗어나는 연산 결과가 발생하는 것을 정수 오버플로(Integer Overflow)라고 합니다.

다음은 long 타입에서 정수 오버플로가 발생하는 예제입니다.

long value = Long.MAX_VALUE;

System.out.println(value);     // 9223372036854775807
System.out.println(value + 1); // -9223372036854775808

Long.MAX_VALUE는 long 타입이 표현할 수 있는 최댓값입니다. 여기에 1을 더하면 더 큰 양수가 되어야 할 것 같지만, 실제로는 표현 범위를 벗어나면서 최솟값인 Long.MIN_VALUE로 바뀝니다.

중요한 점은 Java에서 일반적인 정수 덧셈에 오버플로가 발생하더라도 자동으로 예외가 발생하지 않는다는 것입니다. 그렇기 때문에 연산 결과가 정상적인 값이라고 가정하고 처리하면 예상하지 못한 문제가 발생할 수 있습니다.

그렇다면 최댓값에 1을 더했을 뿐인데 왜 최솟값인 음수로 바뀌는 것일까요?

이는 Java에서 부호 있는 정수를 표현할 때 2의 보수(2’s Complement) 방식을 사용하기 때문입니다.

2의 보수 방식에서는 정해진 비트 수로 양수와 음수를 표현하며, 최상위 비트가 1이면 음수를 나타냅니다. 따라서 Java의 int나 long에서 최댓값에 1을 더하면 정수 오버플로가 발생하면서 비트 패턴이 최솟값을 나타내는 형태로 바뀝니다.

자세한 원리는 아래 포스팅에서 정리했습니다.

참고

(별첨) 최댓값에 1을 더하면 왜 음수가 될까?


2.2. 정수 오버플로로 인한 자원 제한 우회

앞서 살펴본 정수 오버플로는 단순히 잘못된 계산 결과를 반환하는 문제에 그치지 않습니다. 자원 사용량을 검증하는 코드에서도 동일한 문제가 발생할 수 있습니다.

Oracle 가이드라인에서는 다음과 같은 검증 코드를 예제로 소개하고 있습니다.

private void checkGrowBy(long extra) {
    if (current + extra > max) {
        throw new IllegalArgumentException();
    }
}

여기서 current는 현재 사용 중인 자원량, extra는 추가로 사용하려는 자원량, max는 허용된 최대 자원량입니다.

현재 사용량과 추가 사용량의 합계가 허용된 최대 자원량을 초과하면 예외를 발생시키는 구조입니다.

하지만 이 코드에는 current + extra 연산에서 정수 오버플로가 발생할 수 있다는 문제가 있습니다.

예를 들어 다음과 같은 값을 대입해 보겠습니다.

long current = Long.MAX_VALUE - 5;
long extra = 10;
long max = Long.MAX_VALUE;

System.out.println(current + extra);
// -9223372036854775804

System.out.println(current + extra > max);
// false

수학적으로 current + extra는 Long.MAX_VALUE보다 5 큰 값이므로 허용된 범위를 초과합니다. 하지만 Java의 long 타입에서는 정수 오버플로가 발생하면서 연산 결과가 음수로 바뀝니다.

그 결과 current + extra > max 조건식은 false가 되어 검증 로직이 자원 사용량 초과를 감지하지 못합니다. 즉, 자원 사용량을 제한하는 검증 로직이 존재하더라도 정수 오버플로로 인해 제한 조건이 우회될 수 있는 것입니다.

이러한 문제가 실제 자원 할당이나 데이터 처리 로직과 연결된다면 과도한 자원 소비로 이어져 DoS 취약점의 원인이 될 수 있습니다.


3. 정수 오버플로를 예방하는 검증 방법

앞서 살펴본 문제는 자원 사용량을 검증하는 과정에서 정수 오버플로가 발생할 수 있다는 점을 고려하지 않았기 때문에 발생했습니다.

Oracle 가이드라인에서는 이를 예방하기 위한 여러 가지 방법을 소개하고 있습니다.

3.1. 뺄셈을 이용한 범위 검증

첫 번째 방법은 덧셈을 이용한 검증 조건을 뺄셈으로 변경하는 것입니다.

앞서 살펴본 current + extra > max 조건식은 덧셈 결과가 long의 표현 범위를 초과하면 정상적으로 동작하지 않을 수 있습니다.

Oracle에서는 다음과 같이 검증 코드를 변경하도록 안내합니다.

private void checkGrowBy(long extra) {
    if (extra < 0 || current > max - extra) {
        throw new IllegalArgumentException();
    }
}

위 코드에서는 먼저 extra가 음수인지 확인합니다.

이후 허용된 최대 자원량인 max에서 추가 사용량인 extra를 뺀 결과와 현재 사용량인 current를 비교합니다.

즉, 추가 사용량을 더한 결과가 최대 자원량을 초과하는지 검사하는 대신, 추가 사용량을 제외한 허용 범위에 현재 사용량이 포함되는지 검사하는 방식입니다.

예를 들어 앞서 사용했던 값을 다시 적용해 보겠습니다.

long current = Long.MAX_VALUE - 5;
long extra = 10;
long max = Long.MAX_VALUE;

System.out.println(current > max - extra);
// true

max - extra는 Long.MAX_VALUE - 10이므로 정수 오버플로가 발생하지 않습니다.

현재 사용량인 current가 이 값을 초과하기 때문에 조건식은 true가 되고, 정상적으로 자원 사용량 초과를 감지할 수 있습니다. 다만 이 방법은 current와 max가 0 이상이고, 현재 사용량이 유효한 범위에 있다는 전제에서 안전하게 사용할 수 있습니다.

따라서 검증 조건을 변경하는 것뿐 아니라 연산에 사용되는 값의 유효성을 함께 확인하는 것이 중요합니다.

3.2. BigInteger를 이용한 검증

두 번째 방법은 BigInteger를 사용하는 것입니다.

Java의 int와 long은 표현할 수 있는 값의 범위가 정해져 있지만, BigInteger는 고정된 비트 크기에 제한되지 않는 정수 연산을 제공합니다.

Oracle에서는 BigInteger를 이용해 정수 오버플로를 방지하는 방법도 소개하고 있습니다. 이를 앞서 살펴본 예제에 적용하면 다음과 같이 작성할 수 있습니다.

private void checkGrowBy(long extra) {
    BigInteger currentBig = BigInteger.valueOf(current);
    BigInteger maxBig = BigInteger.valueOf(max);
    BigInteger extraBig = BigInteger.valueOf(extra);

    if (extra < 0 ||
        currentBig.add(extraBig).compareTo(maxBig) > 0) {
        throw new IllegalArgumentException();
    }
}

위 코드는 current, extra, max를 BigInteger로 변환한 후 덧셈과 비교 연산을 수행합니다.

BigInteger를 이용하면 long의 최댓값을 초과하는 연산도 표현할 수 있으므로, 정수 오버플로로 인해 검증 조건이 잘못 판단되는 문제를 예방할 수 있습니다.

다만 BigInteger는 일반적인 기본 정수형보다 연산과 객체 생성에 따른 비용이 발생할 수 있습니다. 따라서 단순한 자원 사용량 검증이라면 앞서 살펴본 뺄셈 방식이 더 간결할 수 있습니다.

3.3. Java 표준 API를 이용한 안전한 검증

Oracle 가이드라인에서는 개발자가 직접 범위 검사 로직을 작성하는 대신, Java에서 제공하는 표준 API를 활용하는 방법도 소개하고 있습니다.

Java 9부터 java.util.Objects 클래스에서 제공하는 checkIndex(), checkFromToIndex(), checkFromIndexSize() 메서드를 이용하면 배열이나 데이터 처리 범위가 유효한지 검사할 수 있습니다.

예를 들어 배열의 특정 위치부터 지정된 길이만큼 데이터를 처리하는 경우를 생각해 보겠습니다.

일반적으로 다음과 같이 검증 코드를 작성할 수 있습니다.

if (offset < 0 || length < 0 ||
        offset + length > data.length) {
    throw new IndexOutOfBoundsException();
}

하지만 offset + length 연산에서 정수 오버플로가 발생하면 범위를 벗어난 값이 검증 조건을 통과할 가능성이 있습니다.

int offset = Integer.MAX_VALUE;
int length = Integer.MAX_VALUE;

System.out.println(offset + length); // -2

위 코드에서는 offset + length의 연산 결과가 정수 오버플로로 인해 -2가 됩니다. 따라서 실제로는 배열의 범위를 크게 초과했지만, offset + length > data.length 조건식은 false로 평가되어 검증을 통과할 수 있습니다.

다만 이는 애플리케이션에서 직접 작성한 검증 로직이 잘못된 값을 허용하는 문제이지, Java의 배열 경계 검사가 무력화된다는 의미는 아닙니다. 실제로 잘못된 인덱스로 배열에 접근하면 별도의 예외가 발생할 수 있습니다.

이를 예방하기 위해 Oracle에서는 java.io.OutputStream의 write() 메서드를 예제로 소개하고 있습니다.

public void write(byte b[], int off, int len) throws IOException {
    Objects.checkFromIndexSize(off, len, b.length);

    // len == 0 조건은 반복문의 범위에서 처리됨
    for (int i = 0; i < len; i++) {
        write(b[off + i]);
    }
}

Objects.checkFromIndexSize()는 시작 위치인 off와 처리할 데이터 길이인 len이 배열 b의 유효한 범위에 있는지 검사합니다.

범위를 벗어나면 IndexOutOfBoundsException이 발생하므로, 개발자가 직접 off + len을 계산하여 검사하는 과정에서 발생할 수 있는 정수 오버플로 문제를 예방할 수 있습니다.


3.4. 정수 연산에서 주의해야 할 사항

Oracle 가이드라인에서는 2의 보수 방식에서 음수의 최솟값을 처리할 때 발생하는 문제도 주의해야 한다고 설명합니다.

예를 들어 다음 코드를 살펴보겠습니다.

System.out.println(-Integer.MIN_VALUE);
// -2147483648

System.out.println(Math.abs(Integer.MIN_VALUE));
// -2147483648

Integer.MIN_VALUE는 -2147483648이지만, 이를 양수로 변환한 2147483648은 int 타입으로 표현할 수 없습니다.

따라서 부호를 반전하거나 Math.abs()를 호출하더라도 결과가 음수로 남게 됩니다. 이러한 특성을 고려하지 않고 음수 값을 양수로 변환하여 범위를 검증하면 예상하지 못한 문제가 발생할 수 있습니다.

한편 Java에서는 정수 오버플로를 감지하기 위한 메서드도 제공합니다.

대표적으로 Java 8부터 제공하는 Math.addExact()는 덧셈 결과가 정수형의 표현 범위를 초과하면 ArithmeticException을 발생시킵니다.

long result = Math.addExact(Long.MAX_VALUE, 1);
// ArithmeticException 발생

일반적인 정수 덧셈에서는 오버플로가 발생해도 예외가 발생하지 않지만, Math.addExact()를 사용하면 오버플로를 감지할 수 있습니다. 이외에도 Math.multiplyExact(), Math.decrementExact() 등 다양한 메서드를 제공합니다.

이처럼 Java에서 제공하는 표준 API를 활용하면 정수 오버플로를 고려하지 못해 발생하는 문제를 줄일 수 있습니다.

다만 정수 오버플로를 방지하는 것과 입력값이 유효한지 검증하는 것은 별개의 문제입니다. 따라서 표준 API를 사용하더라도 입력값의 범위와 실제 자원 사용량의 제한 조건은 함께 확인해야 합니다.


4. 다음 포스팅

다음 포스팅에서는 네 번째 DoS 보안 지침인 DOS-4: Implement Robust Error/Exceptions handling for services에 대해 알아보겠습니다.

애플리케이션에서 발생하는 예외를 적절하게 처리하지 못하면 정상적인 서비스 요청에도 영향을 미칠 수 있습니다.

Oracle 가이드라인을 통해 예외 처리와 예외 전파의 차이를 살펴보고, 예상하지 못한 오류 상황에서도 서비스의 안정성을 유지하기 위한 Java 코드 작성 방법을 정리해 보려고 합니다.