소프트웨어 버그를 찾기 위한 18가지 공격

버그를 우연히 찾는 것은 바보라도 할 수 있는 일이다. 테스터가 된다는 것은 버그를 효율적으로 찾는 것을 의미한다. 그러기 위해서는 1) 모든 테스트 케이스에 대한 명확한 목표를 설정해야 하고, 2) 버그가 어디에 숨어 있을지에 대한 이해가 있어야 하고, 3) 버그를 어떻게 드러나게 할지에 대한 방법을 알아야 한다.

실패 모델(Failure Model)

소프트웨어가 실패하는 것은 개발자가 다음을 적절하게 제한하는 데 실패하였기 때문이다.

  • 입력(Inputs)
  • 출력(Outputs)
  • 저장소(Storage)
  • 계산(Computations)

놀랍겠지만 소프트웨어가 하는 일은 입력, 저장, 계산 및 출력 이게 전부다. 따라서 아래와 같은 테스팅 전략(Testing Strategy)을 세운다.

  • 입력에 대한 연구: 입력 제한을 깨뜨리는 공격을 실행한다.
  • 출력에 대한 연구: 출력 제한을 깨트리는 공격을 실행한다.
  • 소프트웨어가 데이터를 저장하는 방식에 대한 연구: 내부 데이터가 손상되도록 설계된 공격을 실행한다.  
  • 소프트웨어가 어떻게 계산을 수행하는지에 대한 연구: 일부러 잘못된 계산을 강제하는 공격을 실행한다.

입력 제한에 대한 공격(Input Constraint Attacks)

  • 공격 1: 모든 에러 메시지가 나타나도록 강제하는 입력을 적용한다.
  • 공격 2: 디폴트값이 적용되도록 하는 입력을 적용한다.
  • 공격 3: 문자 세트(character sets)와 데이터 타입을 탐색한다
  • 공격 4: 입력 버퍼 오버플로우가 발생하도록 한다.
  • 공격 5: 서로 인터액션하는 입력을 찾는다. 그 값들의 다양한 조합을 테스트한다.
  • 공격 6: 동일한 입력을 여러 번 반복한다.

공격 1: 모든 에러 메시지가 발생하도록 강제하는 입력을 적용한다.

소프트웨어가 나쁜 데이터를 억지로 처리하지 않고 걸러내는 것을 확인하기 위해서 너무 크거나, 너무 작거나, 너무 길거나, 너무 짧거나, 허용 가능한 범위를 벗어나거나, 데이터 타입이 잘못된 값을 입력한다. 에러 메시지가 발생하면 이 에러의 경계 조건을 확인한다.

다만 이 공격을 너무 지나치게 할 필요는 없다. 뒤에 이어지는 공격들을 통해 테스트의 완성도가 올라갈 것이므로 이 첫 번째 공격은 애플리케이션이 생성하는 모든 에러 메시지를 적어도 한 번씩 확인하는 정도면 된다.

공격 2: 소프트웨어가 디폴트값을 설정하도록 강제하는 입력을 적용한다.

테스터 입장에서는 종종 아무것도 하지 않고 “OK”만 클릭하다가 애플리케이션이 죽는 것을 보면 되기 때문에 이게 아주 훌륭한 공격이다. 이렇게 단순한 것이 효과적인 공격이 될 수 있는 이유는 테스터가 아무 것도 하지 않는다고 해서 소프트웨어가 아무 것도 수행할 필요가 없는건 아니기 때문이다. 사실, 디폴트 세팅을 하는 것은 상당히 복잡한 프로그래밍 작업이다. 개발자는 루프에 들어가기 전에 또는 함수 호출이 이루어지기 전에 변수를 초기화해야 한다. 만약 이것이 제대로 되지 않으면 내부 변수가 초기화되지 않은 채 사용될 수 있으며, 그 결과는 종종 치명적 실패로 이어진다.

공격 3: 허용되는 문자 세트와 데이터 타입을 탐색한다.

일부 입력 값은 그 자체로 문제가 되기도 한다. 특히 $, %, #, 따옴표 같은 특수 문자는 많은 프로그래밍 언어에서 특별한 의미를 가지기 때문에 입력으로 읽어들일 때 종종 특별한 처리를 필요로 한다. 개발자가 이를 고려하지 못하면 이러한 입력으로 인해 프로그램이 실패할 수 있다. 또한 오랜 예약어(reserved words)가 때때로 문제를 일으키기도 한다.

공격 4: 입력 버퍼 오버플로우를 만든다.

여기서 아이디어는 입력 버퍼 오버플로우를 만들기 위해 긴 문자열(string)을 입력하는 것이다. 애플리케이션이 크래시 발생 후에도 여전히 프로세스를 실행하는 경우가 간혹 있기 때문에 해커가 자주 쓰는 공격이기도 하다. 해커가 긴 입력 문자열의 끝에 실행 가능한 문자열을 첨부하면 프로세스가 이걸 실행할 수도 있다.

테스터가 버퍼 오버런을 심각하게 받아들여야 하는 가장 큰 이유는 보안 우려 때문이다. 그러나 초보 테스터들이 이 공격에 너무 열중하는 바람에 그들에게 긴 문자열 공격을 멈추고 뭔가 다른 것을 시도하라고 권해야 하는 경우가 자주 있다. 그 이유는 많은 개발자들이 이런 일이 발생할 확률이 아주 낮다고 느껴 이를 수정하지 않기 때문이다. 이 공격에 많은 시간을 낭비하기 전에 개발자가 생각하는 입력 필드의 합리적인 길이에 대한 아이디어를 얻는 것이 좋다.

공격 5: 서로 인터액션 하는 입력을 찾고 그 값들의 다양한 조합을 테스트한다.

지금까지는 소프트웨어의 단일 입력 진입점(a single input entry point)을 활용하는 공격만 다루었지만 이 다음 공격은 함께 프로세싱되거나 또는 서로 영향을 미치는 다수의 입력을 다룬다. 예를 들어, 두 개 패러미터로 호출하는 API에서 한 패러미터의 값 선정이 다른 패러미터가 선택한 값을 기반으로 이루어진다(즉, input value dependence). 솔루션을 코딩할 때 그 로직의 복잡성으로 인해 여러 값들의 조합이 부정확하게 프로그래밍 되는 경우가 많다.

그렇다면 어떤 조합(combinations)이 문제가 될까? 이것은 여전히 ​​활발히 연구되고 있는 이슈이며, 우리 경험에 따르면 하나의 출력을 선정한 다음 해당 출력이 발생하도록 하는 입력 조합을 찾는 접근법이 특히 효과적이다.

공격 6: 동일한 입력(또는 입력 시퀀스)를 여러 번 반복한다.

반복은 리소스를 잡아먹고 애플리케이션의 데이터 저장 공간에 스트레스를 주는 효과가 있으며, 또한 바람직하지 않은 부작용을 드러내기도 한다. 불행히도 대부분의 애플리케이션이 자신의 공간 제약과 시간 제약에 대해 알지 못하며, 많은 개발자가 충분한 리소스를 항상 사용할 수 있다는 가정하에서 작업한다.

관련 예를 Word의 수식 편집기(equation editor)에서 볼 수 있다. 이 수식 편집기가 중첩 괄호를 10 레벨 까지만 처리할 수 있다는 사실을 인식 못하는 것처럼 보이며, 실제로 10번째 괄호 쌍 이후에 수식이 사라진다. 이렇게 동일한 입력 또는 입력 시퀀스를 계속해서 반복하면 종종 이상 동작이 나타난다.

출력 제한에 대한 공격(Output Constraint Attacks)

  • 공격 7: 각 입력에 대해 여러 다른 출력이 나타나도록 강제한다.
  • 공격 8: 유효하지 않은 출력이 나타나도록 강제한다.
  • 공격 9: 강제로 출력 크기가 변하도록 만든다.
  • 공격 10: 출력이 출력 공간(output space)을 넘어서도록 만든다.
  • 공격 11: 화면 리프레시를 강제한다.

공격 7: 각 입력에 대해 여러 다른 출력이 생성되도록 강제한다.

하나의 입력이 그것이 적용되는 상황(context)에 따라 여러 다른 출력을 생성하는 일이 흔하다. 예를 들어, 전화 스위치(telephone switch)를 테스트하는 경우 “사용자가 수화기를 든다”라는 입력에 대해 스위치가 생성하는 주요 출력(또는 동작)이 두 가지 이므로 이 둘 모두를 테스트해야 한다. 첫 번때 케이스는 전화기가 유휴(idle) 상태에서 사용자가 수화기를 드는 경우로, 이 때 스위치는 발신음 출력을 생성하고 이를 사용자의 전화기로 보낸다. 두 번째 케이스는 전화가 울리고 있는 경우로, 이 때 스위치는 사용자와 전화를 건 다른 가입자를 연결한다.

가장 중요하거나 자주 사용되는 입력에 대해 가능한 모든 출력을 식별하는 것은 중요한 활동이다. 테스트가 이러한 출력 각각을 커버하는지(즉, 테스팅 실행 범위에 포함되었는지) 확인하는 것이 힘든 작업이지만, 이를 통해 사용자를 짜증나게 하는 중요한 버그를 찾을 수 있기 때문에 수고의 가치가 있다.

공격 8: 강제로 유효하지 않은 출력이 생성되도록 만든다.

자신의 문제 도메인을 진정 이해하는 테스터에게 효과적인 공격이다. 예를 들어, 계산기를 테스트하는 테스터가 일부 함수는 결과에 대해 제한된 범위를 가진다는 것을 이해하고 있다면 해당 결과를 강제로 나타나게 하는 입력 값 조합을 찾는 시도가 가치 있는 노력이다. 그러나 수학을 이해하지 못하는 테스터라면 그러한 노력은 시간 낭비일 가능성이 높고, 심지어 잘못된 결과를 올바른 것으로 해석할 수도 있다.

일례로 Windows NT의 Y2K 관련 버그가 시스템에서 날짜 2001년 2월 29일 표시를 허용했는 데(서비스 팩 5에서 수정됨), 2001년은 윤년이 아니기 때문에 잘못된 출력이다. 윤년 규칙에 익숙하지 않은 테스터라면 분명 이 버그를 놓쳤을 것이다.

공격 9: 출력 크기 또는 차원이 변하도록 강제한다.

이 공격은 강제로 복잡한 출력이 생성되도록 한 다음 출력의 일부 속성을 변경하는 것이다. 사용자 인터페이스 테스팅에서 가장 쉽게 쓸 수 있는 속성으로 출력 크기(output size)가 있다. 즉, 입력 및 입력 문자열의 길이를 변경함으로써 디스플레이 영역을 강제로 다시 계산하도록 만드는 것이다.

개념적인 예를 들면 시계를 9:59으로 설정하고 이것이 10:00으로 넘어가는 것을 관찰한다. 즉, 디스플레이 영역이 4자 길이에서 5자로 바뀌는 것을 확인한다. 또한 반대로 12:59(5자)를 설정한 다음 텍스트가 1:00(4자)으로 줄어드는 것을 확인한다. 개발자가 초기 빈 디스플레이 영역(blank display area)에서 작업하는 코드는 잘 작성하지만 디스플레이 영역에 이미 데이터가 있고 크기가 다른 새 데이터가 이를 교체하는 경우에 대해서는 소홀히 하는 경우가 자주 있다.

공격 10: 출력이 그 목적지 크기/범위를 초과하도록 강제한다.

앞의 공격과 매우 유사한 또 다른 출력 기반 공격이다. 차이점은 디스플레이 내부 영역을 손상시키는 방법을 찾는 대신 이 공격은 디스플레이 외부 영역에 집중한다. 즉, 이번에는 디스플레이 경계를 다시 계산할 필요가 없이 단순히 디스플레이가 범람하게 만드는 일을 한다.

예를 들어, Microsoft Power Point Version 9.0.2716에서 텍스트 상자를 그리고 위 첨자 문자열로 상자를 채운다. 이 위 첨자의 폰트 크기를 아주 크게 변경하면 텍스트 상자에 비해 텍스트가 너무 커서 상단 일부가 잘려 나간다.

공격 11: 화면 리프레시를 강제한다.

사용자가 입력을 적용한 결과로 화면이나 윈도우를 리프레시 하는 것은 윈도우 기반 GUI 사용자들에게 심각한 문제이다. 이것이 개발자들에게는 훨씬 더 큰 문제인 데, 너무 자주 리프레시를 하면 애플리케이션이 느려지고, 리프레시를 안하면 사소한 성가심(예, 사용자에게 강제 리프레시를 요구함)부터 주요 버그(예, 사용자가 작업을 하지 못하게 막음)까지 여러 문제가 발생한다.

리프레시 문제를 찾는 데 있어서 일반적인 아이디어는 화면 상에서 오브젝트를 추가, 삭제, 이동하는 것이다. 이는 배경 오브젝트가 다시 디스플레이 되도록 만들며, 만약 이게 적절한 시점에 적절하게 수행되지 않으면 고전적인 리프레시 버그를 발견한 것이다. 오브젝트를 원래 위치에서 이동하는 거리를 다양하게 변경해 보는 것이 좋다(조금 움직였다가 다시 크게 움직임, 한 두번 움직였다가 다시 십여 번 움직임). 화면 리프레시 관련 Office 2000에서 되풀이되는 문제로 텍스트가 사라지는 현상이 있다.

저장소 제한에 대한 공격(Storage Constraint Attacks)

  • 공격 12: 다양한 초기 조건하에서 입력을 적용한다.
  • 공격 13: 데이터 구조가 너무 많거나 너무 적은 값을 저장하도록 만든다(데이터 구조 오버플로우/언더플로우).
  • 공격 14: 내부 데이터 제한을 위반하도록 만드는 우회 방법을 찾는다.

공격 12: 다양한 초기 조건(initial conditions)을 사용하여 입력을 적용한다.

입력은 종종 다양한 상황하에서 적용될 수 있다. 예를 들어 파일 저장은 어떤 변경이 가해졌을 때 수행될 수도 있고 또는 아무런 변경이 없을 때도 수행될 수 있다. 사용자가 애플리케이션을 사용할 때 마주치게 될 이러한 많은 인터액션을 확인하려면 테스터가 각 입력을 다양한 상황에서 적용해 보는 것이 좋다.

공격 13: 데이터 구조에 너무 많은/너무 적은 값이 저장되도록 강제한다.

모든 데이터 구조의 크기에는 상한이 있다. 일부 데이터 구조는 컴퓨터 메모리 또는 하드 디스크 공간의 용량이 가득 찰 때까지 커질 수 있고, 또 일부 데이터 구조는 고정된 상한선을 갖는다. 예를 들어, 실행 중인 월별 판매 평균은 12개 엔트리로 지정된 배열에 저장되어 있을 수 있다(연도의 각 월에 대해 하나씩).

테스터가 데이터 구조의 한계를 감지할 수 있다면 이 구조에 너무 큰(또는 많은) 값을 강제로 적용해 본다. 숫자가 특히 큰 경우 개발자가 이를 소홀히하여 오버플로우에 대한 에러 케이스를 프로그래밍하지 않았을 수 있다. 데이터 구조 제한이 255, 1023, 32767 등의 데이터 타입 경계에 떨어지는 경우에 특별히 주의를 기울인다. 이러한 제한은 종종 구조의 크기를 선언함으로써 단순 부과되었을뿐 오버플로우 에러 케이스가 결여된 경우가 많다.

언더플로우도 가능성이 있으므로 역시나 테스트되어야 한다. 추가한 것보다 하나 더 많은 요소를 삭제하는 시도를 한다(예, 데이터 구조가 비어 있을 때 삭제 시도, 요소를 하나 추가하고 두 개를 삭제 시도). 이러한 시도를 3~4회 했을 때 애플리케이션이 잘 처리하면 곧 중단한다.

공격 14: 내부 데이터 제한을 수정하는 우회 방법(변칙, 꼼수)을 조사한다.

개발자가 이 공격에 활짝 열려 있는 데, 대부분 프로그램에는 거의 무슨 일이든 할 수 있는 많은 잠재적 방법이 있기 때문이다. 이게 테스터에게 의미하는 바는 동일한 함수를 여러 다른 진입점(entry points)에서 호출할 수 있다는 것이다(각 진입점이 함수의 초기 조건을 충족한다는 전제 하에).

이에 대한 훌륭한 예가 한 학생이 PowerPoint에서 발견한 크래시 버그이다. 이 버그가 표 데이터 구조의 크기와 관련 있다. 표를 생성하는 행위는 최대 크기를 25×25로 제한하고 있다. 그러나 최대 크기로 표를 생성한 다음 프로그램의 다른 위치에서 해당 표에 행 추가나 열 추가를 하면 애플리케이션 크래시가 발생한다. 즉, 소프트웨어 한쪽에서는 26×26 표 생성을 허용하면 안되는 것을 알고 제한하였지만 다른 한쪽에서는 이 규칙을 지키지 못했다.

계산에 대한 공격(Computation Attacks)

  • 공격 15: 유효하지 않은 피연산자 및 연산자 조합으로 실험을 한다.
  • 공격 16: 함수(function)가 자기 자신을 재귀적으로 호출하도록 강제한다.
  • 공격 17: 계산 결과가 너무 크거나 또는 너무 작도록 만든다.
  • 공격 18: 데이터를 공유하거나 또는 인터액션을 제대로 하지 못하는 기능들(features)을 찾는다.

공격 15: 유효하지 않은 피연산자와 연산자 조합을 가지고 실험을 한다.

이 공격은 내부 계산에 쓰인 피연산자(operands)의 데이터 타입 및 허용되는 값에 대한 조사가 필요하다. 테스터가 소스에 접근할 수 있는 경우 이런 정보를 얻을 수 있지만, 그렇지 못한 경우라면 어떤 계산이 수행되고 어떤 타입의 데이터가 사용되는지 최선을 다해 추측해야 한다. 때때로 입력 데이터 또는 저장된 데이터가 합법적인 경계 내에 있지만 일부 유형의 계산이 불법인 경우가 있다. 대표적 예가 ‘0으로 나누기(division by zero)’인데, 0은 유효한 정수이지만 나눗셈 계산의 분모로는 유효하지 않다.

하나 이상의 피연산자가 쓰이는 계산은 잠재적인 피연산자 충돌의 대상이다. 예를 들어, 많은 프로그래밍 언어에서 문자(character) 타입과 숫자(number) 타입 모두 ‘+’ 연산자를 통한 결합을 지원한다. 전자의 경우 문자를 더하면 문자열 접합(concatenation)이 이루어지고 후자의 경우 정수 연산(integer arithmetic)이 수행된다. 그러나 억지로 숫자에 문자를 더하도록 하면(피연산자끼리 충돌) 소프트웨어 시스템이 실패할 수도 있다.

공격 16: 함수가 자신을 재귀적으로 호출하도록 강제한다.

함수는 작업을 수행하기 위해 종종 다른 함수를 호출하며, 때때로 스스로를 호출하기도 한다. 이것을 “재귀(recursion)”라고 하며 개발자가 자주 사용하는 반복 루프(iterative loops)를 대신할 수 있는 강력한 대안이다. 루프와 재귀 모두 실행 횟수가 유한하게 제한되지 않으면 문제가 생긴다. 이런 “무한 루프(infinite loop)”가 흔한 프로그래밍 에러이지만 대개는 단위 테스트에서 잘 걸러진다. 시스템 테스터가 발견할때까지 이런 문제가 해결되지 않은 채 오랫동안 남아 있는 일이 드물다.

그러나 재귀는 완전히 다른 이야기이다. 최신 소프트웨어 애플리케이션은 오브젝트가 스스로 참조할 수 있는 여러 방법을 제공하며, 이는 바꿔말하면 그걸 망가뜨릴 수 있는 새로운 방법들이 테스터에게 있다는 의미이다. 재귀가 부적절하게 구현되면 머신 리소스를 빠르게 소진하고 결국 힙 오버플로우를 생성한다.

공격 17: 계산 결과가 너무 크거나 너무 작도록 강제한다.

이 공격은 계산 결과를 저장하는 데이터 오브젝트의 오버플로우 및 언더플로우를 목표로 한다.

y=x+1과 같은 간단한 계산도 경계 값 주변에서 문제가 생길 수 있다. x와 y가 모두 signed 2바이트 정수 타입이고 x의 값이 32767이면, 결과가 스토리지 오버플로우를 초래하여 이 계산이 실패한다(즉, 계산 결과가 signed 2바이트 정수의 허용 범위를 초과함). 마찬가지 상황이 데이터 타입의 음수 말단에서도 발생할 수 있다. y=x-1 계산에서 x 값으로 -32768을 할당하면 이 계산이 실패한다.

공격 18: 데이터를 공유하거나 또는 제대로 인터액션 못하는 기능들을 찾는다.

서로 다른 애플리케이션 기능(features)이 동일한 데이터 공간을 공유하고 이런 기능들의 인터액션으로 인해 애플리케이션이 실패하는 문제가 있다(데이터를 공유하는 두 개의 기능이 해당 데이터를 각기 상충되는 방식으로 해석).

관련 예로 Word 2000의 문서 페이지에서 각주와 이중 열을 결합할 때 예기치 않은 결과가 나타나는 버그가 있다. Word가 각주의 페이지 너비를 계산할 때 해당 각주 참조 지점을 기준으로 한다. 동일한 페이지에 두 개의 각주가 있고 하나는 이중 열 위치에서 참조하고 다른 하나는 단일 열 위치에서 참조하는 경우 단일 열 각주가 이중 열 각주를 다음 페이지로 밀어낸다. 이 때 각주 참조 지점과 페이지 말단 사이의 모든 텍스트도 함께 다음 페이지로 밀려난다. 아래 스크린샷이 이 문제를 보여주는 데, 두 번째 열의 사라진 텍스트는 각주와 함께 다음 페이지로 밀려난 상태이다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다