탐지 함수가 0/1을 리턴하면 후킹이 그걸 뒤집는다
탈옥·후킹 탐지 함수의 고정 반환값은 hook 한 줄로 뒤집힌다. 반환값을 매 호출 랜덤 정수로 바꾸고 호출 측이 그 값을 알고리즘으로 합의 검증하게 만들어 우회를 원천에서 끊은 설계 기록.
0/1을 리턴하는 탐지 함수는 이미 진 함수다
탈옥·디버깅·후킹 탐지 함수의 전형적인 모양은 단순하다. 환경을 검사하고, 위험하면 0, 안전하면 1을 돌려준다(부호는 구현마다 다르다). 호출 측은 그 값을 if 한 줄로 받아 분기한다.
동작 자체는 멀쩡하다. 문제는 이 함수가 신뢰할 수 없는 환경 안에서 실행된다는 데 있다. 탐지 대상이 곧 실행 주체다.
Frida 같은 동적 계측 도구는 함수의 진입·반환을 가로채 반환값을 바꿀 수 있다. 공격자가 할 일은 단순하다. 탐지 함수를 후킹해 반환값을 항상 “안전” 쪽 상수로 고정하면, 검사 로직이 아무리 정교해도 결과는 한 값으로 눌린다.
개념을 코드로 보면 이렇다. 탐지 함수 내부를 분석할 필요조차 없다.
// 공격자 측 — 반환값을 "안전"으로 고정
Interceptor.attach(detectFn, {
onLeave(retval) {
retval.replace(1); // 무엇을 검사하든 항상 1
}
});
출력이 두 값뿐이면, 그중 하나로 고정하는 것이 우회의 전부가 된다. 우리가 검사 항목을 늘릴수록 함수는 무거워지지만, 후킹 한 줄을 막지는 못한다. 비대칭이 공격 쪽에 있다.
이건 AppSuit iOS의 탐지 정적 라이브러리를 0→1로 단독 구현하던 2024년에 마주친 벽이었다. 탐지 항목을 추가하는 방향으로는 이 비대칭이 뒤집히지 않는다는 게 분명했다.
아래 그림이 그 비대칭이다. 검사 로직이 아무리 길어도, 출력이 한 비트로 모이는 순간 후킹 한 줄이 그 비트를 덮는다.
값을 숨기지 말고, 값을 신뢰 불가능하게 만든다
처음 떠올린 방향은 탐지 함수를 더 감추는 쪽이었다. 인라인, 심볼 제거, 호출 위치 분산. 전부 후킹을 어렵게 할 뿐 무의미하게 만들지는 못했다.
반환 지점이 하나로 모이는 한, 그 지점을 찾으면 끝이다. 방향이 틀렸다고 판단했다.
대신 반환값의 의미 구조를 바꿨다. 핵심은 두 가지다.
- 탐지 함수는
0/1이 아니라 매 호출마다 다른 랜덤 정수를 돌려준다. 안전/위험 여부는 그 정수가 만족해야 하는 관계 안에 인코딩된다. - 호출 측은 반환값을 상수와 단순 비교하지 않는다. 그 값을 다른 입력과 함께 받아 알고리즘으로 처리해 합의가 성립하는지를 본다.
의사코드로 그리면 단일 비교가 합의 검증으로 바뀐다.
// 기존 — 한 값으로 고정하면 끝
if (detect() == SAFE) { proceed(); }
// 변경 — 회차마다 정답이 달라진다
nonce = issueChallenge(); // 호출 측이 이번 회차의 기준을 정함
r = detect(nonce); // 매 호출 다른 랜덤 정수
if (agree(r, nonce)) { proceed(); } // 관계가 성립할 때만 통과
후킹이 반환값을 어떤 상수로 고정하는 순간, 호출 측이 매 호출마다 기대하는 값이 달라지기 때문에 합의가 깨진다. 공격자는 더 이상 “안전을 뜻하는 한 값”을 모른다. 그 값은 호출마다 바뀌고, 호출 측만 그 회차의 정답을 알기 때문이다.
한 번 캡처한 반환값으로 다음 호출을 통과시킬 수 없다.
이 패턴의 핵심은 탐지를 값에서 합의로 옮긴 데 있다. 탐지 함수의 출력은 그 자체로 진실을 말하지 않는다. 호출 측과의 약속을 만족할 때만 의미를 갖는다.
신뢰할 수 없는 환경에서 단일 비트는 가짜로 만들기 쉽다. 하지만 매 회차 달라지는 합의는 환경 한 곳을 후킹하는 것만으로는 일관되게 위조하기 어렵다.
아래가 그 흐름이다. 회차마다 기준이 새로 발급되고, 고정된 상수는 다음 회차에서 곧장 어긋난다.
랜덤 소스와 합의 검증 코드의 형태를 여러 안으로 받아 비교했다.
다만 “값이 아니라 합의로 옮긴다”는 판단과, 항목 추가가 아니라 반환 구조를 바꿔야 한다는 방향 결정은 내 몫이었다. 이 패턴은 사내 대안 제시로 정리됐고, 이후 다른 탐지 제품에 점진 이식하는 후보로 팀 업무 분장에 들어갔다.
검증과 일반화 — 이건 끝나는 싸움이 아니다
검증 기준은 단순했다. 탐지 함수의 반환 지점을 후킹해 상수로 고정했을 때, 두 버전이 어떻게 갈리는지를 봤다.
- 단일 0/1 버전: 한 번에 우회됐다.
- 랜덤 합의 버전: 호출이 거듭될수록 통과율이 무너졌다.
“한 번 잡으면 끝”과 “매 콜마다 다시 풀어야 함”의 차이가 그대로 나타났다.
다만 정직하게 적어 둔다. 이 패턴은 후킹을 불가능하게 만들지 않는다. 호출 측 검증 로직 자체를 분석해 합의 알고리즘을 재현하면 다시 우회가 가능하다.
목표는 차단이 아니라 비대칭을 되돌리는 것이다. 공격자가 한 줄로 끝내던 일을, 매 호출 단위로 합의를 풀어야 하는 일로 바꿔 비용을 올린다.
같은 구도는 탈옥 탐지 전반에서 반복된다. 새 탈옥 변종이 나오면 탐지 항목을 더하고, 도구는 그 항목을 우회하는 변종을 또 내놓는다. 동적 계측 도구가 정적 포트 검사를 피해 설정을 바꾸면, 탐지는 검사 범위를 넓히는 식으로 쫓아간다.
항목을 더하는 대응은 늘 한 발 늦는다. 반환값 랜덤화가 의미 있었던 이유는, 항목 경쟁이 아니라 “탐지 결과를 어떻게 신뢰할 것인가”라는 한 단계 아래의 문제를 건드렸기 때문이다.
탐지는 결국 값을 읽는 일이 아니다. 신뢰할 수 없는 환경에서 합의가 성립하는가를 확인하는 일이다.
출력이 두 값뿐인 함수는 그 환경 안에서 늘 한 비트로 압축되고, 한 비트는 가장 뒤집기 쉬운 단위다. 값을 늘리는 게 아니라, 값을 신뢰 불가능하게 만드는 쪽이 옳았다.