Skip to content

noir1458/ptrace_practice

Repository files navigation

https://tartanllama.xyz/posts/writing-a-linux-debugger/breakpoints/

디버거 작동 학습

01. 기본 세팅

02. SW breakpoint

objdump로 심볼 주소를 뽑고, ASLR끄고 브레이크포인트 거는 방식

ASLR 을 끄지 않았을때

2개의 터미널을 띄우고 한 터미널에서 ./minidbg ./target 을 실행한뒤에 다른 프롬프트에서 진행

➜  02.swbreakpoint git:(master) ✗ pid=$(pgrep -n target)
➜  02.swbreakpoint git:(master) ✗ echo "PID=$pid"
PID=20533
➜  02.swbreakpoint git:(master) ✗ head -n1 /proc/$pid/maps
555555554000-555555555000 r--p 00000000 00:45 10977524091715716          /mnt/d/git/ptrace_practice/02.swbreakpoint/target
➜  02.swbreakpoint git:(master) ✗ python3 - << 'PY'
base = int('555555554000',16)
offset = int('1169',16)
print(hex(base+offset))
PY
0x555555555169

PID를 찾고, 맵을 읽어서 text 베이스주소를 찾는다 그리고 오프셋을 더해서 실제 브레이크 주소를 구한다

➜  02.swbreakpoint git:(master) ✗ nm -n target |
grep main
                 U __libc_start_main@GLIBC_2.34
0000000000001169 T main

이게 오프셋

➜  02.swbreakpoint git:(master) ✗ ./minidbg ./target
minidbg> break 0x555555555169
Set breakpoint at address 0x555555555169
minidbg> cont
[stopped] signal 5
minidbg> cont
start
middle
end
[exit] status 0
minidbg>

ASLR로 인해서 base address가 랜덤화 되어 오프셋만 아는걸로는 실제 위치를 바로 찾을 수 없다. 그런데 커널이 현재 프로세스의 메모리 배치를 그대로 보여주는 인터페이스가 있으므로, 거기서 베이스 주소를 읽고 오프셋을 더해서 실제 주소를 구할 수 있도록 한다. 이런식이면 ASLR을 일부러 끌 필요가 없다.

ASLR, PIE를 껐을때

현재 minidbg는 자식 프로세스에서 personality(ADDR_NO_RANDOMIZE)를 호출해 ASLR을 비활성화한다. 그러면 커널이 베이스 주소를 랜덤화 하지 않음

그리고 빌드 옵션으로 PIE를 끄자, PIE는 실행 파일 전체를 위치 독립적 코드로 만드는 빌드 방식으로 텍스트/데이터 세그먼트가 특정 가상 주소에 묶이지 않고 어떤 베이스 주소로 로드되어도 정상 동작하게 하는것. 기존에는 PIE로 인해 파일 안의 오프셋 = 런타임 주소가 성립하지 않았다

-no-pie로 빌드하면 실행 파일이 ET_DYN이 아닌 고정베이스로 링크되므로 로더가 어떤 주소에도 자유롭게 옮겨야 할 필요가 없어진다

두 조건이 동시에 맞으면 파일 안에서 오프셋과 런타임 주소가 일대일로 매칭된다.

➜  02.swbreakpoint git:(master) ✗ nm -n target | grep main
                 U __libc_start_main@GLIBC_2.34
0000000000401156 T main
➜  02.swbreakpoint git:(master) ✗ ./minidbg ./target
minidbg> break 0x401156
Set breakpoint at address 0x401156
minidbg> cont
[stopped] signal 5
minidbg> cont
start
middle
end
[exit] status 0

임의 프로그램을 직접 실행할 때는 setarch -R 또는 personality(ADDR_NO_RANDOMIZE)로 ASLR을 끄거나, 위 ASLR 켠 상태의 절차처럼 /proc/<pid>/maps 기반으로 주소를 계산하는 것 중 필요한 쪽을 골라 쓰면 된다.......

03. register_and_memory

➜  03.register_and_memory git:(master) ✗ ./minidbg ./target
minidbg> break 0x555555555169
Set breakpoint at address 0x555555555169
minidbg> cont
[stopped] signal 5
minidbg> cont
start
middle
end
[exit] status 0
minidbg>

02번 디버거와 기본 흐름은 같지만, register dump/read/writememory read/write 명령으로 레지스터·메모리를 직접 다루고, 브레이크포인트 복구를 위해 PTRACE_SINGLESTEPrip 재설정을 수행하며, ptrace 에러를 예외로 보고하고 모든 신호를 로그로 확인할 수 있도록 동작을 확장

그러니까 이전 명령때는 0xcc에서 복구하고 진행하는 기능이 없었다는것. 02번 디버거는 이후 명령이 깨지거나 이상현상이 있을 수 있었다.

04. Elves_and_Dwarves

ELF + DWARF 정보로 파일:라인에서 실제 코드 주소를 찾아서 소스 라인 브레이크포인트를 걸 수 있다고 한다

예제 코드는 없음

  1. DWARF 열기
  • 실행파일 또는 해당 .so의 .debug_line 섹션 파싱 여러 컴파일 유닛(CU) 중에서 파일 경로가 일치하는 - - 라인 테이블 선택(경로 normalize 주의: 상대/절대, 디렉터리 엔트리 분리됨) ????????? - 나중에 설명 추가하기
  1. 라인 -> 링크 타임 주소 결정
  • 라인 테이블의 행들 중 (file == F, line == L) 에 해당하는 statememt 시작 (is_stmt=true) 항목을 고름
  • 가능하면 prologue_end 표식(PE)이 붙은 지점을 선호(함수 프롤로그를 넘긴 첫 명령에 걸려서 스텝의 품질이 좋아진다)
  • 인라인/최적화로 같은 라인에 여러 주소가 있을 수 있는데 -> 보통 가장 작은 주소나 is_stmt/PE 를 우선
  1. ASLR/PIE 보정(런타임 주소 계산)
  • 대상 바이너리가 PIE거나 공유 라이브러리면, 링크타임 주소 그대로 쓰면 안되고
  • 디버깅 중인 프로세스 /proc//maps에서 해당 객체의 로드 베이스를 구함(예: libfoo.so가 0x7f...000에 매핑).
  • runtime_addr = load_base + (link_addr - link_base) 로 보정 (비-PIE 실행 파일은 보통 load_base == link_base라 보정 불필요.)

이 부분은 02번에서 했던것 같다

  1. breakpoint 설치
  • 계산된 runtime_addr에 한 바이트를 저장해 두고(원바이트 백업), 0xCC로 패치
  • 히트 시에는 이전에 설명한 BP 넘기기 루틴(원바이트 복원→RIP=bp_addr→싱글스텝→재설치)을 적용.

이 부분이 03번에서 진행한것

  1. 함수명으로 브레이크 포인트를 건다면??
  • 심볼이 살아 있으면 .symtab/.dynsym에서 함수 이름으로 엔트리 주소를 얻고, 위와 같은 PIE 보정 후 BP를 심을 수 있어.
  • 심볼이 없더라도 DWARF의 .debug_info에서 DW_TAG_subprogram(함수 DIE) 의 low_pc/high_pc로 함수 시작 주소를 얻는 방법도 있음.

이 부분은 아직 실습 안함

변수의 내용을 읽는 법

  • DW_AT_location : 변수가 어디에 있는지 알려주는 위치 표현식/목록
  • DW_AT_frame_base : frame base가 어디인지 알려주는 표현식/목록

하면서 내용 수정...

05. Source and Signals

run()과 초기화 흐름

  • debugger 생성자는 실행 파일을 open(O_RDONLY)으로 연 뒤 libelfin 로더를 사용해 m_elf, m_dwarf를 초기화하고, FD는 즉시 닫는다. 이후 모든 DWARF 조회가 이 핸들을 재사용한다.
  • run()은 최초 wait_for_signal(false)로 exec 직후 SIGTRAP을 받아들이고, 조기 종료/시그널을 체크한 다음 initialize_load_address()로 PIE 오프셋을 준비한다. 이어서 PTRACE_SETOPTIONS(EXITKILL, TRACESYSGOOD)를 걸고 REPL을 시작한다.

세부 함수 메모

void debugger::initialize_load_address() {
    m_load_address = 0;

    if (m_elf.get_hdr().type != elf::et::dyn) {
        return;
    }

    std::ifstream map("/proc/" + std::to_string(m_pid) + "/maps");
    if (!map) {
        report_error("failed to open /proc/" + std::to_string(m_pid) + "/maps");
        return;
    }

    std::string addr;
    if (std::getline(map, addr, '-')) {
        try {
            m_load_address = std::stoull(addr, nullptr, 16);
        } catch (const std::exception& ex) {
            report_error(std::string("failed to parse load address: ") + ex.what());
        }
    } else {
        report_error("unexpected format while reading load address");
    }
}

대상 프로세스의 로드 베이스 주소를 계산한다. run() 입구에서 한 번 호출되며, ELF 타입이 ET_DYN이 아닐 경우 그대로 0을 유지한다. /proc/<pid>/maps 첫 매핑의 시작 주소를 파싱해 m_load_address에 저장하고, 읽기/파싱 실패는 report_error로 기록한다.

uint64_t debugger::offset_load_address(uint64_t addr) const {
    if (m_load_address == 0 || addr < m_load_address) {
        return addr;
    }
    return addr - m_load_address;
}

PIE 바이너리와 공유 라이브러리에서만 로드 베이스를 빼고, 그렇지 않은 경우(비-PIE, 이미 보정된 주소)는 그대로 돌려준다. addr < m_load_address 같은 비정상 상황도 그냥 원본 주소를 반환하도록 방어 로직을 둔 상태.

dwarf::die debugger::get_function_from_pc(uint64_t pc) {
    for (auto &cu : m_dwarf.compilation_units()) {
        if (die_pc_range(cu.root()).contains(pc)) {
            for (const auto& die : cu.root()) {
                if (die.tag == dwarf::DW_TAG::subprogram) {
                    if (die_pc_range(die).contains(pc)) {
                        return die;
                    }
                }
            }
        }
    }
    throw std::out_of_range{"Cannot find function"};
}

특정 PC가 속한 함수(DW_TAG::subprogram) DIE를 찾는다. 현재는 선형 탐색이라 단순하지만 브레이크/스텝 정지 시 함수명 확인, 향후 스코프 분석 등에 바로 활용 가능하다. 인라인 함수나 LTO 결과까지 엄밀히 처리하려면 추가 로직이 필요하다.

dwarf::line_table::iterator debugger::get_line_entry_from_pc(uint64_t pc) {
    for (auto &cu : m_dwarf.compilation_units()) {
        if (die_pc_range(cu.root()).contains(pc)) {
            auto& lt = cu.get_line_table();
            auto it = lt.find_address(pc);
            if (it == lt.end()) {
                throw std::out_of_range{"Cannot find line entry"};
            }
            return it;
        }
    }
    throw std::out_of_range{"Cannot find line entry"};
}

PC에 대응하는 라인 테이블 엔트리를 반환한다(파일 경로, 라인 번호 포함). 정지 시 소스 콘텍스트를 뽑는 기본 자료로 쓰이며, 주소가 정확히 매칭되지 않으면 std::out_of_range를 던져 호출자에게 처리를 맡긴다. 최적화 수준이 높을수록 테이블 자체가 비어 있을 수 있다는 점만 주의하면 된다.

void debugger::print_source(const std::string& file_name, unsigned line, unsigned n_lines_context){
    std::ifstream file{file_name};
    if (!file) {
        report_error("failed to open source file: " + file_name);
        return;
    }

    // Work out a window around the desired line
    auto start_line = line <= n_lines_context ? 1 : line - n_lines_context;
    auto end_line = line + n_lines_context + (line < n_lines_context ? n_lines_context - line : 0) + 1;

    char c{};
    auto current_line = 1u;
    // Skip lines up until start_line
    while (current_line != start_line && file.get(c)) {
        if (c == '\n') {
            ++current_line;
        }
    }

    if (current_line > end_line) {
        return;
    }

    std::cout << (current_line == line ? "> " : "  ");

    // Write lines up until end_line
    while (current_line <= end_line && file.get(c)) {
        std::cout << c;
        if (c == '\n') {
            ++current_line;
            // Output cursor if we are at the current line
            std::cout << (current_line == line ? "> " : "  ");
        }
    }

    std::cout << std::endl;
}

지정된 파일의 주변 라인을 출력하며 현재 라인을 >로 강조한다. 파일 열기에 실패하면 바로 에러를 신고하고 조용히 리턴한다. 브레이크포인트나 단일 스텝에서 멈춘 직후 호출된다.

siginfo_t debugger::get_signal_info() {
    siginfo_t info{};
    if (ptrace(PTRACE_GETSIGINFO, m_pid, nullptr, &info) == -1) {
        perror("ptrace(PTRACE_GETSIGINFO)");
        std::memset(&info, 0, sizeof(info));
    }
    return info;
}

waitpid로 STOP 상태를 확인한 직후 호출해 마지막 신호의 siginfo_t를 얻는다. PTRACE_GETSIGINFO가 실패하면 errno 로그를 남기고 0으로 초기화된 구조체를 반환해 이후 로직이 무너지는 것을 방지한다.

void debugger::handle_sigtrap(siginfo_t info) {
    switch (info.si_code) {
    //one of these will be set if a breakpoint was hit
    case SI_KERNEL:
    case TRAP_BRKPT:
    {
        const auto pc = get_pc() - 1;
        set_pc(pc); // put the pc back where it should be
        std::cout << "Hit breakpoint at address 0x" << std::hex << pc << std::dec << '\n';

        const auto offset_pc = offset_load_address(pc);
        try {
            auto line_entry = get_line_entry_from_pc(offset_pc);
            print_source(line_entry->file->path, line_entry->line, 2);
        } catch (const std::exception& ex) {
            report_error(std::string("failed to print source: ") + ex.what());
        }
        return;
    }
    //this will be set if the signal was sent by single stepping
    case TRAP_TRACE:
        return;
    default:
        std::cout << "Unknown SIGTRAP code " << info.si_code << std::endl;
        return;
    }
}

SIGTRAP 원인 분기 처리하는 함수

  1. get_pc()로 현재 RIP/PC 읽고
  2. 소프트웨어 BP(int3 = 0xcc) 있으므로 PC-1을 보정한다
  3. 보정된 PC를 로드 오프셋으로 조정한 뒤 get_line_entry_from_pc()print_source()를 통해 현재 소스 문맥을 출력한다
  4. 단일 스텝 복구는 continue/next 등의 명령에서 step_over_breakpoint()가 담당한다

x86외 아키텍쳐 BP는 별도 처리가 필요하다

int debugger::wait_for_signal(bool report) {
    int wait_status = 0;
    if (waitpid(m_pid, &wait_status, 0) < 0) {
        perror("waitpid");
        return -1;
    }

    if (WIFEXITED(wait_status)) {
        if (report) {
            std::cout << "[exit] status " << WEXITSTATUS(wait_status) << '\n';
        }
        return wait_status;
    }

    if (WIFSIGNALED(wait_status)) {
        if (report) {
            std::cout << "[killed] by signal " << WTERMSIG(wait_status) << '\n';
        }
        return wait_status;
    }

    if (WIFSTOPPED(wait_status)) {
        const auto sig = WSTOPSIG(wait_status);
        const auto info = get_signal_info();

        switch (sig) {
        case SIGTRAP:
            handle_sigtrap(info);
            break;
        case SIGSEGV:
            if (report) {
                std::cout << "Received SIGSEGV (code " << info.si_code
                          << ") at address 0x" << std::hex
                          << reinterpret_cast<std::uintptr_t>(info.si_addr)
                          << std::dec << '\n';
            }
            break;
        default:
            if (report) {
                std::cout << "Stopped by signal " << strsignal(sig) << '\n';
            }
            break;
        }
    }

    return wait_status;
}

waitpid 루프의 중앙 허브, 정지 원인에 따라서 분기한다

  1. waitpid(m_pid, &status, 0)
  2. WIFSTOPPED(status) 면 get_signal_info()
  3. info.si_signo에 따라서
    • SIGTRAP > handle_sigtrap(info)
    • SIGSEGV > si_code/주소 로그(옵션으로 메모리 덤프)
    • 기타 > strsignal로 간단히 알림
void debugger::step_over_breakpoint() {
    const auto it = m_breakpoints.find(get_pc());
    if (it == m_breakpoints.end()) {
        return;
    }

    auto& bp = it->second;
    if (!bp.is_enabled()) {
        return;
    }

    bp.disable();
    if (ptrace(PTRACE_SINGLESTEP, m_pid, nullptr, nullptr) == -1) {
        perror("ptrace(PTRACE_SINGLESTEP)");
    } else {
        wait_for_signal(false);
    }
    bp.enable();
}

히트한 BP를 한번 우회하는 표준 루틴 BP 해제 → PTRACE_SINGLESTEP → 재대기 → BP 재설치 순서로 진행한다. continue나 다른 실행 명령이 재개되기 전에 호출돼, 방금 히트한 소프트웨어 브레이크포인트를 원래 명령으로 되돌린다.

실행 흐름 예시 - SW breakpoint hit

  1. target int3 도달 > SIGTRAP/TRAP_BPKPT 정지
  2. wait_for_signal() > get_signal_info() > handle_sigtrap()
  3. get_pc > set_pc(pc-1) (0xcc 보정)
  4. 사용자가 continue 등을 호출하면 step_over_breakpoint()가 원본 바이트를 복구하고 단일 스텝 후 재설치
  5. get_line_entry_from_pc() > print_source() 로 현재 소스 표시
  6. 사용자 입력 루프 (계속/다음 스텝/다른 BP 설정 등...)

06. source level stepping

단일 스텝 헬퍼

  • get_offset_pc()는 현재 RIP에서 로드 베이스를 빼 DWARF 오프셋을 돌려준다. 반대로 offset_dwarf_address(uint64_t)는 DWARF 주소에 로드 베이스를 더해 런타임 주소를 만든다.
  • single_step_instruction()PTRACE_SINGLESTEP 호출과 wait_for_signal()을 감싼 가장 작은 스텝 단위이고, 오류 발생 시 바로 리포트한다.
  • single_step_instruction_with_breakpoint_check()는 현재 PC 지점에 소프트웨어 BP가 있으면 step_over_breakpoint()로 우회 후 계속, 아니면 위 단일 스텝을 호출한다. stepi 명령은 이 함수를 호출한 뒤 get_line_entry_from_pc(get_offset_pc()) 결과를 출력해준다.

고수준 소스라인 스텝 명령

  • step_in() (step): 현재 라인 번호를 기억했다가, 다른 라인이 나올 때까지 single_step_instruction_with_breakpoint_check()를 반복한다. 라인 정보를 찾지 못하면 에러를 로그로 남기고 단일 스텝만 수행한다.
  • step_over() (next): 현재 PC가 속한 함수의 low_pc/high_pc 구간을 조사한 뒤, 같은 함수 안의 다른 소스 라인들과 함수 리턴 주소에 임시 BP를 건다. continue_execution()으로 진행 후, 히트된 임시 BP를 모두 해제한다. 라인 테이블이 없거나 함수 탐색이 실패하면 경고 후 단일 스텝으로 대체한다.
  • step_out() (finish): 현재 프레임의 rbp+8 위치에 있는 리턴 주소에 BP를 세팅해 continue_execution() 하고, 복귀 시 임시 BP를 제거한다.
  • remove_breakpoint()는 해당 주소에 BP가 실제 존재할 때만 disable → erase를 수행해 예외 없이 정리한다.

기타 메모

  • DWARF 함수 범위를 얻기 위해 low_pc/high_pc 속성을 읽어 런타임 주소로 변환한다. high_pc가 상대 오프셋인 케이스도 지원하도록 변환 로직을 보강했다.
  • step/next 명령은 각 단계에서 get_line_entry_from_pc(get_offset_pc())가 실패할 수 있으므로, 에러 메시지를 남기고 가능한 한 현재 실행을 이어가도록 했다.

07. source level breakpoints

  • break foo.cpp:42처럼 파일:라인 형식으로 브레이크포인트를 걸면 DWARF 라인 테이블을 찾아 offset_dwarf_address()로 런타임 주소를 계산한 뒤 설치한다.
  • break 함수이름은 해당 이름을 가진 DW_TAG_subprogramlow_pc → prologue 건너뛴 첫 라인을 찾아 그 지점에 브레이크포인트를 심는다.
  • break 0xADDR도 그대로 유지되지만, PIE 바이너리에서도 자동으로 로드 베이스를 빼주는 덕분에 동일한 커맨드로 사용 가능하다.
  • symbol <name> 명령을 추가해 .symtab/.dynsym를 순회하며 일치하는 심볼 정보를 나열한다. 새로 도입한 lookup_symbol()symbol_type·to_symbol_type() 헬퍼와 함께 이 기능을 담당한다.
  • 헤더에는 함수/라인 브레이크포인트와 심볼 조회용 멤버 선언을 추가해 구현부와 인터페이스가 맞춰졌다.
  • 외부 의존성 없이 쓰려고 third_party/libelfin에 libelfin 헤더(dwarf/*.hh, elf/*.hh)와 정적 라이브러리(libdwarf++.a, libelf++.a)를 포함했다. 빌드할 때는 -I../third_party/libelfin/include/dwarf -I../third_party/libelfin/include/elf -L../third_party/libelfin/lib -ldwarf++ -lelf++ 옵션을 추가하면 된다.

test

➜  07.source_level_breakpoints git:(master) ✗ ./minidbg ./target2
Unknown SIGTRAP code 0
minidbg> break d
Set breakpoint at address 0x555555555187
minidbg> cont
Hit breakpoint at address 0x555555555187

  void d() {
>     int foo = 4;
      c();
  }


minidbg> cont
[exit] status 0
minidbg> quit
bye
*** stack smashing detected ***: terminated
[1]    9955 IOT instruction (core dumped)  ./minidbg ./target2

잘 되는 것으로 보인다. quit 이후 문제생기는것은 지금은 고치지 않고 넘어간다.

08.stack_unwinding

void debugger::print_backtrace() {
    std::ios_base::fmtflags original_flags{std::cout.flags()};
    try {
        constexpr std::size_t max_frames = 64;
        std::size_t frame_index = 0;

        auto current_pc = get_pc();
        auto frame_pointer = get_register_value(m_pid, reg::rbp);

        while (frame_index < max_frames) {
            const auto func = get_function_from_pc(offset_load_address(current_pc));
            const auto link_addr = dwarf::at_low_pc(func);
            const auto runtime_addr = offset_dwarf_address(link_addr);
            std::string func_name;
            try {
                func_name = dwarf::at_name(func);
            } catch (...) {
                func_name.clear();
            }

            std::cout << "frame #" << frame_index++ << ": 0x"
                      << std::hex << runtime_addr << std::dec << ' '
                      << (func_name.empty() ? "<unknown>" : func_name) << '\n';

            if (func_name == "main" || frame_pointer == 0) {
                break;
            }

            const auto return_address = read_memory(frame_pointer + sizeof(uint64_t));
            if (return_address == 0) {
                break;
            }

            const auto next_frame = read_memory(frame_pointer);
            if (next_frame == 0 || next_frame <= frame_pointer) {
                break;
            }

            frame_pointer = next_frame;
            current_pc = return_address - 1;
        }
    } catch (const std::out_of_range&) {
        // Unwinding reached code without DWARF coverage.
    } catch (const std::exception& ex) {
        report_error(std::string("backtrace failed: ") + ex.what());
    }
    std::cout.flags(original_flags);
}
➜  08.stack_unwinding git:(master) ✗ ./minidbg ./target2
Unknown SIGTRAP code 0
minidbg> break b
Set breakpoint at address 0x555555555147
minidbg> cont
Hit breakpoint at address 0x555555555147

  void b() {
>     int foo = 2;
      a();
  }


minidbg> bt
frame #0: 0x55555555513b b
frame #1: 0x55555555515b c
frame #2: 0x55555555517b d
frame #3: 0x55555555519b e
frame #4: 0x5555555551bb f
frame #5: 0x5555555551db main
minidbg> cont
[exit] status 0
minidbg> quit
bye

출력 주소는 이제 실행 시점 주소(ASLR 보정 적용)로 나온다. main까지 올라가거나 프레임 포인터 체인이 끊기면 자동으로 멈추며, 최악의 경우 예외를 잡아 사용자에게 보고한다. 이전에 quit 직후 스택 가드가 깨지던 문제도 더 이상 재현되지 않았다. (백트레이스만 보고 싶다면 cont 하지 말고 bt 이후 quit로 종료하면 된다.)

09.handling_variables

  • vars, p <name> 명령을 추가해 현재 프레임의 지역 변수/인자를 읽는다. DW_OP_fbreg, DW_OP_breg*, DW_OP_reg*, DW_OP_addr, 상수/stack_value 정도만 해석하는 가벼운 위치식 인터프리터를 직접 붙였다.
  • 함수의 DW_AT_frame_baseDW_OP_call_frame_cfa인 경우에는 rbp + 16(리턴 주소/old rbp를 뛰어넘는 포인터 크기 × 2)으로 CFA를 잡고, 그 외에는 기본적으로 rbp로 폴백한다.
  • 변수 타입 크기는 DW_AT_byte_size나 포인터/참조 태그로 계산한다. 정보가 없으면 포인터 크기(8바이트)만큼 읽도록 했다.
  • 메모리 덤프는 리틀엔디언 바이트를 뒤집어 0x00112233 형태로 출력한다.
  • 현재는 loclist, piece, 레지스터 조합 같은 고급 규칙은 미지원이라 O2 이상 최적화된 바이너리에서는 일부 변수를 못 잡을 수 있다.
➜  09.handling_variables ./minidbg ./target2
Unknown SIGTRAP code 0
minidbg> break c
Set breakpoint at address 0x555555555163
minidbg> cont
Hit breakpoint at address 0x555555555163

  void c() {
>     int foo = 3;
      b();
  }


minidbg> vars
foo = 0x00000003
minidbg> p foo
foo = 0x00000003
minidbg>

10. advanced_topics

  • Remote debugging: 디버거 ↔ 디버그 스텁 ↔ 트레이시 구조. GDB 원격 프로토콜($Z0,<addr>,<kind>#<checksum> 등)로 명령을 주고받으며, IDE/MI 계층과도 연동 가능하다.
  • 공유 라이브러리 추적: ELF PT_DYNAMIC/.dynamic 기반 rendezvous 구조를 찾아 현재 로드된 so 목록을 읽고, 업데이트 훅에 브레이크포인트를 걸어 dlopen/dlclose를 감시한다.
  • 식 평가: 단순 식은 DWARF 변수 조회, 복잡한 식은 IR 해석이나 JIT 실행이 필요하다. LLDB는 Clang→LLVM IR→인터프리트, 혹은 mmap한 코드 블록을 실행해 결과를 얻는다.
  • 멀티스레드 지원: PTRACE_SETOPTIONSPTRACE_O_TRACECLONE을 설정해 새 스레드 TID를 받아 /proc/<pid>/task에서 상태를 추적한다. GDB는 libthread_db로 이 과정을 추상화한다.
  • 현재 구현에는 적용되지 않았지만, 향후 원격 스텁·동적 로딩 모니터·식 평가 엔진·스레드 스케줄러를 붙일 때 참고할 개념 정리.

고려해야할 방해 로직들...

  • ptrace 감지/배제: ptrace(PTRACE_TRACEME) 재호출, getppid() 체크, /proc/self/status의 TracerPid 확인 등으로 디버거 존재를 감지해 종료하거나 기능을 바꿉니다.
  • 시그널·예외 조작: SIGTRAP, SIGILL 등을 고의로 발생시켜 디버거가 예상대로 처리하지 못하면 흐름이 깨지게 하거나, 신호 처리기를 덮어쓰기도 합니다.
  • 타이밍·성능 체크: 루틴 실행 시간을 반복 측정해 디버깅으로 인해 지연이 늘어나면 다른 코드 경로를 타도록 만듭니다.
  • 코드 무결성/셀프체크: 자신을 해시·서명해 두고 변조가 감지되면 종료합니다. 암호 루틴 전후로 키/상태 검사를 넣어 디버가 수정한 흔적을 찾아내기도 합니다.
  • 난독화·JIT·특수 ABI: 암호 루틴을 난독화하거나 런타임에 생성해 디버깅을 어렵게 합니다. 커널 모드, 가상화, SGX 등의 보호 영역에서 실행되면 사용자 공간 디버거로는 접근이 힘든 경우도 있습니다.

즉 “ASLR/PIE만 끄면 끝”이 아니라, 표적인 프로그램이 어떤 보호를 구현했는지 먼저 확인하세요. 디버거 회피 로직이 있다면 이를 제거하거나 우회(패치, LD_PRELOAD, 커널 모듈 등)한 뒤 본격적으로 분석하는 순서가 일반적입니다.

  • API 후킹으로 감지되는 케이스만 다룬다면 일반적인 ptrace 회피나 시그널 트랩 같은 “디버거 탐지” 로직은 우선순위가 낮습니다. 해당 프로그램이 실제로 쓰는 보호 기법을 먼저 확인하고, 그 범위 안에서만 방어 우회를 준비해도 충분합니다.
  • 다만 암호 루틴 자체가 self-integrity 체크나 타이밍 체크를 포함할 가능성은 염두에 두세요. 후킹으로 빠져나간다는 걸 전제로 코드를 바꿔 놓았다면 디버거에서도 같은 조건을 만족시켜야 합니다.
  • 즉, API 후킹으로 잡히지 않는 커스텀 암호화 루틴만 디버거로 뜯어보면 된다면, 디버거 회피 로직 추가 여부만 살펴보고 나머지는 과감히 무시해도 괜찮습니다.

먼저 함수 후킹으로 api콜하는거를 잡아내고 직접 구현된 암호화만 이걸로 포착하고 있으니까 몇가지 케이스는 덜 고려해도 되는거 아닌가... 개인적인 생각

Third-party notice

  • third_party/libelfinaclements/libelfin에서 가져온 파일이며 MIT License를 따른다. 하위 디렉터리에 라이선스 사본을 함께 포함했다.

About

ptrace

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors