ARM Pointer Authentication(PAC)와 BTI로 ROP/JOP 공격 막기

스택 카나리·NX/DEP·ASLR로 오랫동안 막아온 리턴 주소 조작을, 공격자는 새 코드 주입 대신 기존 코드 조각을 이어붙이는 ROP/JOP로 우회해왔다. ARM은 ARMv8.3-A의 Pointer Authentication(PAC)과 ARMv8.5-A의 Branch Target Identification(BTI)으로 이를 하드웨어 레벨에서 막는다. PAC는 리턴 주소/함수 포인터에 서명을 붙이고 사용 직전 검증하고, BTI는 간접 분기의 착지 지점을 제한한다. 둘 다 HINT 인코딩을 써서 구형 CPU에서는 NOP로 무시되는 상위 호환 설계다. 이 글에서는 원리, 커널 지원 현황, GCC로 직접 컴파일해 objdump/readelf로 확인한 결과를 정리한다.

PAC와 BTI 동작 원리

명령어동작
PACIASP함수 진입 시 LR(X30)에 SP를 컨텍스트로, 프로세스별 키(APIAKey)로 계산한 PAC를 포인터 상위 비트에 삽입
AUTIASP리턴 직전 같은 값/키로 PAC를 재계산해 검증 — 불일치 시 상위 비트에 오류 패턴이 남아 실제 역참조 시 폴트
BTI c / BTI j / BTI jc간접 분기(call/jump/둘 다)의 착지 지점 표시 — 이 지점이 아니면 Branch Target Exception

키는 용도별 5개(APIAKey/APIBKey/APDAKey/APDBKey/APGAKey)가 있고 PACIASP/AUTIASP는 APIAKey를 쓴다. 공격자가 리턴 주소를 덮어써도 키를 모르면 유효한 PAC를 못 만들어 AUTIASP에서 걸린다. PACIASP 자체가 BTI c 착지 지점도 겸하므로, 리턴 주소를 서명하는 함수는 별도 BTI c가 또 들어가지 않는다.

리눅스 커널의 PAC/BTI 지원

커널 옵션은 CONFIG_ARM64_PTR_AUTH(유저스페이스)/CONFIG_ARM64_PTR_AUTH_KERNEL(커널 자체)로 나뉜다. 하드웨어가 지원하고 옵션이 켜져 있으면 exec*() 시점에 프로세스마다 난수 키를 할당하고(스레드 간 공유, fork() 후에도 유지), prctl(PR_PAC_RESET_KEYS)로 재설정할 수 있다.

기능hwcap판별 조건
주소 인증(A key)HWCAP_PACAID_AA64ISAR1_EL1.APA/API == 0b0001
generic 인증HWCAP_PACGID_AA64ISAR1_EL1.GPA/GPI == 0b0001
BTIHWCAP2_BTIID_AA64PFR1_EL1.BT == 0b0001

PACA/PACG는 AT_HWCAP, BTI는 AT_HWCAP2로 노출되고(/proc/cpuinfo Features에도 paca/pacg/bti로 표시), KVM은 KVM_ARM_VCPU_PTRAUTH_ADDRESS/_GENERIC 플래그로 게스트 노출 여부를 정한다(VHE 필요, 꺼진 게스트에서 PAC 실행 시 UNDEFINED 예외). Fedora 33+와 Ubuntu는 aarch64 패키지를 기본적으로 -mbranch-protection으로 빌드한다.

실전: GCC로 컴파일해서 확인하기

#include <stdio.h>

int add(int a, int b)
{
    return a + b;
}

int compute(int x, int y)
{
    int sum = add(x, y);
    return sum * 2;
}

int main(void)
{
    int result = compute(3, 4);
    printf("result = %d\n", result);
    return 0;
}
$ aarch64-linux-gnu-gcc -O0 -mbranch-protection=pac-ret+bti -o pac_demo pac_demo.c
$ objdump -d pac_demo

0000000000400684 <add>:
  400684:	d503245f 	bti	c
  400688:	d10043ff 	sub	sp, sp, #0x10
  ...
  4006a4:	d65f03c0 	ret

00000000004006a8 <compute>:
  4006a8:	d503233f 	paciasp
  4006ac:	a9bd7bfd 	stp	x29, x30, [sp, #-48]!
  ...
  4006c4:	97fffff0 	bl	400684 <add>
  ...
  4006d8:	d50323bf 	autiasp
  4006dc:	d65f03c0 	ret

leaf 함수(다른 함수를 안 부르는) add()bti c만, non-leaf인 compute()paciasp/autiasp가 들어간다. leaf까지 서명하려면 -mbranch-protection=pac-ret+leaf+bti. 오브젝트 파일의 GNU 프로퍼티 노트로 보호 여부를 확인한다.

$ aarch64-linux-gnu-gcc -O0 -mbranch-protection=pac-ret+bti -c -o pac_demo.o pac_demo.c
$ readelf -n pac_demo.o

Displaying notes found in: .note.gnu.property
  Owner                Data size 	Description
  GNU                  0x00000010	NT_GNU_PROPERTY_TYPE_0
      Properties: AArch64 feature: BTI, PAC

주의사항

  • 링크 시 프로퍼티가 조용히 사라질 수 있다. crt 스타트업 오브젝트(crt1.o 등) 중 하나라도 같은 프로퍼티 노트가 없으면 ld가 최종 바이너리에서 빼버린다(실제 재현 확인). 배포 전 최종 실행 파일에 readelf -n을 다시 돌릴 것. 강제하려면 -Wl,-z,force-bti(입력 오브젝트마다 경고 출력).
  • ELF 프로퍼티 없으면 커널이 강제 안 한다. bti 명령어가 있어도 GNU_PROPERTY_AARCH64_FEATURE_1_BTI 노트가 없으면 커널이 BTI 강제 매핑을 안 걸어 사실상 NOP.
  • 정적 링크는 하나라도 빠지면 전체 무효화. 서드파티 정적 라이브러리도 같은 옵션으로 빌드됐는지 확인.
  • 만능 방어 아님. 서명된 포인터 정보 유출, data-only 공격, 이미 유효한 착지 지점만 쓰는 정교한 JOP 체인까지는 못 막는다 — ASLR·카나리·NX와 함께 겹겹이 쌓는 계층 중 하나.
  • CPU 지원 확인 먼저. PAC는 ARMv8.3-A, BTI는 ARMv8.5-A부터. 더 오래된 코어(Cortex-A72 이전 등)는 NOP로만 동작 — 보호 효과 없이 코드 크기만 증가.

마무리

컴파일러 옵션 하나(-mbranch-protection)로 ROP/JOP의 핵심 전제를 하드웨어 레벨에서 무너뜨릴 수 있고, 미지원 CPU에서는 NOP로만 동작해 배포 부담도 적다. 직접 빌드하는 aarch64 바이너리에도 적용을 검토할 만하지만, 컴파일러가 명령어를 넣어줬다고 커널이 실제로 강제한다는 뜻은 아니므로 최종 바이너리에서 readelf -n으로 직접 확인하는 습관이 필요하다.

참고

답글 남기기