PHP 약타입 비교 연산의 동작 원리와 보안 우회 기법

서론

PHP 의 동적 타입 특성은 개발 편의성을 제공하지만, 특정 상황에서는 예상치 못한 보안 취약점을 야기할 수 있습니다. 특히 값의 비교 연산 과정에서 일어나는 암묵적인 타입 캐스팅 현상은 웹 애플리케이션에서 인증 우회나 입력값 검증 무효화 등의 문제로 활용되곤 합니다. 본 글에서는 이러한 느슨한 비교(Loose Comparison) 로직이 어떻게 작동하는지 기술적으로 분석하고, 실제 코드로 확인해봅니다.

1. 비교 연산자와 타입 조옮김 (Type Juggling)

PHP 에서 등호 연산자 (==) 는 좌우 피연산자의 타입이 상이할 경우, 비교 전 하나의 타입으로 자동 변환된 후 대등 여부를 판단합니다. 이를 느슨한 비교라고 부르며, 주로 숫자와 문자열 간의 비교에서 주의가 필요합니다.

공식 규격에 따르면, 양쪽이 수치형 문자열이거나 한쪽이 숫자라면 수치 기반으로 계산됩니다. 이때 문자열 내 포함된 숫자 패턴이 어떻게 파싱되는지 확인해야 합니다.

<?php
// 문자열 내 숫자 식별 로직 테스트
$testStr = "1024abc";
$valTarget = 1024;

if ($testStr == $valTarget) {
    echo "일치\n"; // 문자열 앞부분 숫자로만 인식되어 참이 됨
} else {
    echo "불일치\n";
}

// 비수치성 접두어가 있는 경우
$badStr = "hello123";
if ($badStr == 0) {
    echo "0 으로 간주됨\n"; // php 버전差异에 따라 결과가 달라질 수 있음
}

위 예시처럼 문자열이 숫자로 해석될 때, 첫 번째 문자까지 읽다가 알파벳이나 특수문자가 발견되면 그 전까지의 숫자를 사용합니다. 또한 1e5 형식은 과학기수법으로 인식되어 실제 큰 정수로 변환됩니다.

2. 제어구조에서의 적용

switch 문도 내부에서 동일한 느슨한 비교 규칙을 따릅니다. 이 특징을 이용하면 변수의 타입을 다르게 전달하여 분기를 제어할 수 있습니다.

<?php
$input = $_REQUEST['code'];

switch ($input) {
    case 0:
        print 'A';
        break;
    case 1:
        print 'B';
        break;
    default:
        print 'Unknown';
}
// 사용자에게 "0a"와 같은 값을 입력받아도 숫자 0 으로 처리可能被함

3. 암호화 함수의 취약점: MD5 충돌

MD5 와 같은 해시 함수는 고정 길이 문자열을 반환합니다. 그런데 해시 결과물이 0e 로 시작하고 이후가 숫자만인 경우, PHP 는 이를 지수형태의 0 을 의미하는 것으로 잘못 해석할 수 있습니다.

예를 들어, 정상 비밀번호와 공격자 입력값의 MD5 가 서로 다르더라도, 둘 다 0e... 형식이면 == 연산 시 모두 0 으로 취급되어 일치 판정이 떨어질 수 있습니다.

이를 찾기 위한 검색 스크립트는 다음과 같이 구현할 수 있습니다.

import hashlib
import itertools
import random

def find_magic_hash(target_len=8):
    chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    
    for attempt in range(10000):
        candidate = ''.join(random.choices(chars, k=target_len))
        h = hashlib.md5(candidate.encode()).hexdigest()
        
        # 0e 로 시작하며 뒤에 숫자가 붙는 구조인지 확인
        if h.startswith('0e') and h[2:].isdigit():
            return candidate, h
    return None, None

result_str, result_hash = find_magic_hash()
print(f"Found: {result_str} -> {result_hash}")

이러한 특수한 해시값 조합을 사전에 확보하거나 실시간으로 생성하여 인증 절차를 통과시킬 수 있습니다.

4. 함수 호출 시 타입 강제 변환 이슈

strcmp() 는 원래 두 문자열을 비교하는 함수이지만, PHP 8 이전 버전에서는 배열 등이 전달되었더라도 경고를 출력하면서 0 을 반환하는 일이 있었습니다.

<?php
$a = "secret";
$b = $_GET['check']; 

// b 가 배열([]) 이더라도 경고가 나오지만 return 0 일 수 있어 우회가 가능
if (strcmp($a, $b) === 0) { 
    // 엄격한 비교를 해야 안전하나 == 사용 시 위험
    echo "Access Granted";
}

또한 배열 탐색 함수인 in_array()array_search() 는 기본 옵션상 느슨한 비교를 사용합니다. 세 번째 인자로 true 를 주입하지 않으면 타입 구분을 하지 않고 값만 비교하게 됩니다.

5. 부울 값과 문자열의 관계

부울 타입인 true 를 문자열과 비교할 때는 주의가 필요합니다. PHP 에서는 true 와 빈 문자열이 아닌 대부분의 문자열 값을 비교했을 때 true 로 간주합니다.

<?php
$config = json_decode('{"is_admin": true}');

// true 와 "admin" 이 일치하는지 확인하는 로직
if ($config->is_admin == "admin") {
    // true == "admin" 은 참으로 평가됨
    echo "Admin Role";
}

이는 JSON 또는 직렬화된 데이터를 탈출하여 권한을 상승시키는 데 악용될 소지가 있습니다.

6. 안전한 비교를 위한 방안

위와 같은 문제를 근본적으로 해결하려면 타입과 값 모두를 일치시켜야 하는 삼중 등호 (===) 를 사용하는 것이 원칙입니다. 이 연산자는 타입 불일치가 발생하면 바로 false 를 반환하므로 타입 변형을 통한 우회를 차단합니다.

<?php
$value1 = "123";
$value2 = 123;

if ($value1 === $value2) {
    echo "Match";
} else {
    echo "No Match (String vs Integer)"; // 실행됨
}

7. 실습 문제 분석

다음과 같은 단순한 인증 코드가 있다고 가정해봅시다.

<?php
$id = $_GET['id'];
// id 가 숫자여야 하지만 1 과 비교 시
if (!is_numeric($id)) {
    echo "Not Number";
} elseif ($id == 1) {
    echo "Success";
}

is_numeric() 를 통과하지 않는 문자열임에도 불구하고 == 연산 시 숫자로 캐스팅되어 참이 되는 케이스를 만들어야 합니다. 예를 들어 1a 와 같은 값을 전송하면 숫자로 인식되지 않을 수 있으나, 맥락에 따라 캐스팅 논리가 다를 수 있으므로 정확히 테스트해야 합니다. 일반적인 CTF 환경에서는 1sj 처럼 앞뒤에 텍스트를 섞어 숫자 변환 시 앞부분만 취하도록 유도하기도 합니다.

다른 예시로 MD5 인증 우회를 요구하는 경우가 있습니다.

<?php
$target = md5('QNKCDZO');
$user_input = $_GET['inp'];
$h2 = md5($user_input);

if ($user_input !== 'QNKCDZO' && $target == $h2) {
    echo "Flag";
}

여기서는 입력값이 원본과 같지 않아야 하며, MD5 값의 비교 시 0e 계열의 해시 충돌을 일으키는 문자열 (예: 240610708 등) 을 찾으면 조건을 만족할 수 있습니다.

태그: PHP WebSecurity TypeJuggling CTF SecurityAudit

6월 28일 20:31에 게시됨