[PWN] ret2dlresolve

Magnolia·2026년 3월 29일

ret2dlresolve

ret2dlresolve (Return To dl-resolve)는 ELF의 동적 링킹 메커니즘을 악용해서 프로그램이 원래 호출하지 않던 함수를 동적 링커가 직접 찾아서 연결(resolve)하게 만드는 공격 기법이다.

ELF 바이너리가 동적 링크되어 있으면 프로그램은 외부 함수의 실제 주소를 처음부터 전부 알고 시작하지 않는다.

예를 들어 프로그램이 puts()를 사용한다고 해보자.
실행 파일 안에는 libc의 실제 puts 주소가 들어있는 것이 아니라 대략 다음과 같은 구조가 있다.

  • PLT : 외부 함수를 호출하기 위한 중간 코드
  • GOT : 실제 resolved된 함수 주소를 저장하는 테이블
  • .dynsym : 동적 심볼 테이블
  • .dynstr : 심볼 이름 문자열 테이블
  • .rela.plt, .rel.plt : relocation 정보
  • ld.so : 실제 심볼 주소를 찾아주는 동적 링커

즉 프로그램은 puts@plt를 호출하고 처음 호출될 때는 ld.so가 puts가 libc에서 어디에 있는지를 찾아서 GOT에 쓴다.

ret2dlresolve는 이 과정을 악용한다.


ret2libc vs ret2dlresolve

보통 ret2libc는 다음 흐름을 따른다.

  1. GOT/PLT 등을 이용해 libc 주소 leak
  2. libc base 계산
  3. system(), execve() 등의 실제 함수 주소 계산
  4. 해당 함수 호출

하지만 ret2dlresolve는 이런 식으로 libc base를 직접 계산하지 않는다.
대신 동적 링커(ld.so)가 원래 하던 일을 공격자가 원하는 방향으로 유도한다.

쉽게 말하면 내가 직접 system()의 주소를 계산하는 것이 아니라 동적 링커에서 이 심볼을 해결해달라고 속여서 system 주소를 찾게 만드는 것이다.


Lazy Binding

ret2dlresolve는 Lazy Binding 구조를 공격에 이용한다.

함수가 처음 호출될 때만 resolve가 일어나고 그 다음부터는 GOT에 이미 libc 주소가 들어있으므로 바로 호출되는 방식이다.


PLT

PLT는 외부 함수 호출용 stub이다.

puts@plt는 다음의 동작을 한다.

puts@plt:
    jmp [puts@got]
    push reloc_index
    jmp plt0
  1. 일단 GOT에 들어있는 주소로 jmp한다.
  2. 처음에는 GOT에 실제 libc 주소가 없다.
  3. 대신 GOT에는 다시 PLT 내부 흐름으로 돌아가게 만드는 값이 들어있다.
  4. 그러면 reloc_index를 스택에 올리고 plt0으로 이동한다.
  5. plt0_dl_runtime_resolve를 호출한다.
  6. 동적 링커는 reloc_index를 보고 어느 심볼을 resolve 해야하는지 판단한다.

즉 PLT 엔트리는 단순히 점프 코드가 아니라 이 함수의 재배치 번호는 몇 번이다 라는 정보를 동적 링커에게 전달하는 역할을 하기도 한다.


동작 원리

원래는 puts@pltputs에 해당하는 relocation index를 넘긴다.

만약 공격자가 제어 가능한 메모리에 가짜 relocation 엔트리와 가짜 symbol 엔트리, system 문자열을 만들어놓고 동적 링커가 그걸 진짜라고 믿게 만들 수 있다면 동적 링커는 이 relocation 엔트리는 system 심볼을 resolve 하라는 뜻이구나 라고 생각할것이다.

그 결과 ld.so가 libc에서 system 주소를 찾아오고 그걸 이용해서 공격자가 원하는 호출이 가능해진다.


조건

  1. libc leak이 어려운 상황에서 사용하면 좋다.
  2. 바이너리가 동적 링킹 되어있어야 가능하다.
  3. ROP로 read 같은 함수가 호출되야한다. (공격자가 fake 구조체를 메모리에 써야 하기 때문)

ret2dlresolve를 위한 ELF 구조

.dynstr

.dynstr은 문자열 테이블이다.
심볼 이름들이 이 영역에 문자열로 저장된다.

예를 들어 다음과 같은 문자열들이 있다.

puts
read
write
system
printf

Symbol Table의 st_name 필드는 이 .dynstr 내부에서의 오프셋을 가진다.

Elf64_Sym.st_name = 0x1234.dynstr + 0x1234 위치에 puts\0 같은 문자열이 있다고 보는 것이다.


.dynsym

.dynsym은 동적 심볼 테이블이다.
각 외부 심볼에 대한 정보가 Elf64_Sym 구조체 배열로 저장된다.

64비트에서는 보통 다음과 같은 형태이다.

typedef struct {
    Elf64_Word    st_name;
    unsigned char st_info;
    unsigned char st_other;
    Elf64_Half    st_shndx;
    Elf64_Addr    st_value;
    Elf64_Xword   st_size;
} Elf64_Sym;
  • st_name : .dynstr 안에서 심볼 이름 문자열이 시작되는 오프셋
  • st_info : 심볼의 타입/바인딩 정보
  • st_value : 실제 주소가 아니라 공유 라이브러리 심볼의 경우 상황에 따라 해석된다. ret2dlresolve에서는 보통 0으로 한다.

동적 링커는 이 구조체를 보고 resolve해야 하는 심볼 이름은 .dynstr + st_name 이구나를 이해한다.


.rela.plt

64비트 ELF에서는 .rela_plt를 주로 사용한다.
이건 PLT용 relocation 엔트리들의 배열이다.

구조체는 다음과 같은 형태이다.

typedef struct {
    Elf64_Addr   r_offset;
    Elf64_Xword  r_info;
    Elf64_Sxword r_addend;
} Elf64_Rela;
  • r_offset : resolve된 실제 주소를 어디에 써 넣을지 나타내는 위치, 보통 GOT 엔트리 주소이다.
  • r_info : 두 정보가 합쳐져 있다.
    • 상위 비트 : symbol index
    • 하위 비트 : relocation type

64비트에서는 보통 다음처럼 다룬다.

ELF64_R_SYM(r_info)  -> symbol index
ELF64_R_TYPE(r_info) -> relocation type

PLT용으로 자주 쓰이는 타입은 R_X86_64_JUMP_SLOP이다.

r_info는 어떤 심볼을 어떤 방식으로 resolve 할지를 나타낸다.

  • r_addend : 추가값이다. ret2dlresolve에서는 보통 0으로 한다.

동적 링커의 흐름

동적 링커는 무엇을 보고 심볼을 찾는가
흐름을 단순화 하면 다음과 같다.

  1. _dl_runtime_resolve가 호출된다.
  2. 전달받은 relocation index를 기반으로 .rela.plt의 특정 엔트리를 찾는다.
  3. 그 엔트리의 r_info에서 symbol index를 꺼낸다.
  4. .dynsym[symbol_index]를 읽는다.
  5. 그 안의 st_name을 이용해 .dynstr + st_name 문자열을 읽는다.
  6. 예를 들어 puts가 나오면 libc에서 puts를 찾는다.
  7. 찾은 주소를 r_offset 위치에 써 넣는다.
  8. 그 주소로 점프하거나 이후 호출이 가능해진다.

즉 공격자는 relocation table, symbol table, string table이 3개를 가짜로 만들고 동적 링커가 그 가짜 엔트리를 참조하도록 해야한다.


공격 흐름

  • ROP 체인으로 read 호출
    공격자는 제어 가능한 writable memory에 fake 데이터를 쓴다.

  • PLT0 또는 적절한 resolver 경로 호출
    동적 링커를 실행시켜 fake relocation을 처리하게 만든다.

  • resolve 결과를 이용해 셸 유도
    셸 획득


fake 구조체

프로그램의 .rela.plt, .dynsym, .dynstr은 보통 읽기 전용이다.

그래서 공격자는 새로운 fake 엔트리를 writable한 메모리에 만들고 동적 링커가 그 위치를 원래 테이블의 일부처럼 해석하게 만들어야 한다.


relocation index / reloc_arg

PLT는 동적 링커에게 대체로 몇 번째 relocation 엔트리를 써야 하는가를 전달한다.

64비트에서 흔히 계산하는 값은 다음과 같다.

reloc_arg = (fake_rela_addr - jmprel) / sizeof(Elf64_Rela)

여기서 jmprel은 실제 .rela.plt 시작 주소이고 fake_rela_addr은 공격자가 만든 가짜 Elf64_Rela 주소이다.

즉 동적 링커 입장에선 jmprel + reloc_arg * sizeof(Elf64_Rela) 위치를 relocation 엔트리로 읽는다.

그런데 그 결과가 .rela_plt 범위를 넘어 fake rela를 가리키게 만들면 된다.


fake Elf64_Rela

이런식으로 만든다.

Elf64_Rela fake_rela;
fake_rela.r_offset = target_got;
fake_rela.r_info   = ((uint64_t)sym_index << 32) | R_X86_64_JUMP_SLOT;
fake_rela.r_addend = 0;

여기서 하나씩 보자


  • r_offset
    resolve된 주소를 써 넣을 위치이다. 보통 writable한 GOT 엔트리 하나를 고른다.

이 말은 동적 링커가 system 주소를 찾아서 read_got에 써버리게 할 수 있다는 뜻이다.

이후 read@plt를 호출하면 system이 호출된다ㅋㅋ


  • r_info
    상위 32비트는 symbol index, 하위 32비트는 relocation type이다.

그래서 보통 r_info = (sym_index << 32) | 7 처럼 쓴다.
여기서 7은 R_X86_64_JUMP_SLOT 이다.

R_X86_64_JUMP_SLOT은 은 ELF 동적 링크 과정에서 PLT/GOT를 통해 외부 함수를 해결할 때 사용되는 relocation 타입이다.

즉 의미는 sym_index 번째 심볼을 JUMP_SLOT 방식으로 resolve해라 라는 뜻이다.


fake Elf64_Sym

이런식으로 만든다.

Elf64_Sym fake_sym;
fake_sym.st_name  = offset_to_string_in_dynstr_style;
fake_sym.st_info  = 0;
fake_sym.st_other = 0;
fake_sym.st_shndx = 0;
fake_sym.st_value = 0;
fake_sym.st_size  = 0;

중요한건 st_name이다.

동적 링커는 .dynstr + st_name 위치에서 심볼 이름 문자열을 읽는다.

그래서 fake_sym의 st_name은 결국 system\x00 문자열을 가리키도록 맞춰야한다.

st_name은 절대 주소가 아니다.

st_name.dynstr 기준 offset이기 때문에 st_name = fake_string_addr - dynstr로 계산한다.

왜나하면 동적 링커는 symbol_name = dynstr + sym->st_name;와 같이 해석하기 때문에 st_name에는 메모리 절대주소를 넣으면 안되고 .dynstr 기준 상대 오프셋을 넣어야한다.


alignment

Elf64_Sym의 크기는 0x18 바이트이기 때문에 fake_sym을 만들 때는 정렬을 해줘야한다.

(fake_sym_addr - dynsym) / 0x18

형태가 정확히 맞아 떨어져야 하기 때문에

익스코드에서

align = 0x18 - ((fake_sym_addr - dynsym) % 0x18)
fake_sym_addr += align

와 같이 계산한다.

정렬이 안맞으면 동적 링커가 엉뚱한 위치를 심볼 엔트리로 읽는다.


정리

ret2dlresolve는 가짜 relocation, symbol, string 엔트리를 만들어 동적 링커가 공격자가 원하는 libc 심볼을 직접 resolve 하게 만드는 공격 기법이다.

pwntools에서 Ret2dlresolvePayload가 복잡한 계산을 자동화 해주기 때문에 개꿀이다.

아주 신선한 공격 기법이지만 어렵게 느껴졌다.

0개의 댓글