취약점분석/Reversing

[Reversing] angr CTF 문제 풀이 - 4 (15 ~ 17)

poiri3r 2026. 1. 5. 18:34

안녕하세요 오늘은 angr CTF 문제 풀이를 마무리 해보도록 하겠습니다.

 

15_angr_arbitrary_read

이번에 문제에 Goodjob이란 문자열을 출력하는 부분이 없네요.

Arbitrary Read는 임의 주소 읽기로 공격자가 프로세스 메모리 내의 주소를 지정하여 그 안에 저장된 값을 읽어내는 취약점을 말합니다.

그럼 메모리 상에 Good Job.을 출력하는 부분이 있을테니 찾아보겠습니다.

 

if문에서 key값이 일치할 때 키 값을 Good job 문자열을 읽어오거나 s에 넣어야할 것 같은데 s에 값을 쓰는건 read가 아니라 write문제에 들어갈 것 같은 느낌.

문제 풀이 코드를 보겠습니다.

# Some of the source code for this challenge:
#
# #include <stdio.h>
# #include <stdlib.h>
# #include <string.h>
# #include <stdint.h>
#
# // This will all be in .rodata
# char msg[] = "${ description }$";
# char* try_again = "Try again.";
# char* good_job = "Good Job.";
# uint32_t key;
#
# void print_msg() {
#   printf("%s", msg);
# }
#
# uint32_t complex_function(uint32_t input) {
#   ...
# }
#
# struct overflow_me {
#   char buffer[16];
#   char* to_print;
# };
#
# int main(int argc, char* argv[]) {
#   struct overflow_me locals;
#   locals.to_print = try_again;
#
#   print_msg();
#
#   printf("Enter the password: ");
#   scanf("%u %20s", &key, locals.buffer);
#
#   key = complex_function(key);
#
#   switch (key) {
#     case ?:
#       puts(try_again);
#       break;
#
#     ...
#
#     case ?:
#       // Our goal is to trick this call to puts to print the "secret
#       // password" (which happens, in our case, to be the string
#       // "Good Job.")
#       puts(locals.to_print);
#       break;
#    
#     ...
#   }
#
#   return 0;
# }

주석으로 소스코드 일부가 주어져있고 

# The general strategy for crafting this script will be to:
# 1) Search for calls of the 'puts' function, which will eventually be exploited
#    to print out "Good Job."
# 2) Determine if the first parameter of 'puts', a pointer to the string to be
#    printed, can be controlled by the user to be set to the location of the
#    "Good Job." string.
# 3) Solve for the input that prints "Good Job."

다음과 같이 힌트도 있습니다.

전체적인 코드 구성을 보니까 후킹을 통해서 puts에 제약 조건을 걸고 코드 바꿔치기를 하는것 같은데 .. 

 

일단 입력값에 어떤걸 넣어야할 지 생각을 해보면

입력을 두개 받는데 하나는 key 값이므로 첫번째 입력은 14274378일 것이고, 위의 소스코드에서 key가 일치하는 케이스에서 어떤 scanf인자가 들어갈 때 puts가 출력하는 메모리 번지가 Goodjob의 메모리 번지와 일치하는지를 찾아야 될 것 같습니다.

 

문제를 자세히 보니까

key가 일치할 때 출력하는 s는 저희가 입력하는 v4와 0x10차이가 나는데, 저희가 입력할 수 있는 버퍼는 20바이트이므로 버퍼 오버플로우가 발생합니다.

 

이제야 좀 감이 잡히는데 

 class ReplacementScanf(angr.SimProcedure):
    # Hint: scanf("%u %20s")
    def run(self, format_string, ???, ???):
      # %u
      scanf0 = claripy.BVS('scanf0', ???)
     
      # %20s
      scanf1 = claripy.BVS('scanf1', ???)

이건 scanf를 후킹하는 함순데 어차피 인자가 두개니 두개만 설정을 해줍니다.

첫번째 인자는 4바이트니 32비트로 맞춰주고 두번째는 20바이트이므로 160비트로 넣어줍니다.

 for char in scanf1.chop(bits=8):
        # Ensure that each character in the string is printable. An interesting
        # experiment, once you have a working solution, would be to run the code
        # without constraining the characters to the printable range of ASCII.
        # Even though the solution will technically work without this, it's more
        # difficult to enter in a solution that contains character you can't
        # copy, paste, or type into your terminal or the web form that checks
        # your solution.
        # (!)
        self.state.add_constraints(char >= ???, char <= ???)

해당 주석을 해석해보면 scanf로 들어온 입력값의 글자가 사람이 눈으로 볼 수 있고 키보드로 입력이 가능한 ASCII로 되도록 강제하는 코드입니다. 32~126으로 설정을 해줍니다. 

 

  # Warning: Endianness only applies to integers. If you store a string in
      # memory and treat it as a little-endian integer, it will be backwards.
      scanf0_address = ???
      self.state.memory.store(scanf0_address, scanf0, endness=project.arch.memory_endness)
      ...

      self.state.globals['solution0'] = ???
      ...

해당 scanf0_address는 아까 위의 run함수의 두번째 인자인 format_string으로 넣어줍니다.

def check_puts(state):
   
    puts_parameter = state.memory.load(???, ???, endness=project.arch.memory_endness)

다음은 check_puts입니다. 해당 함수는 puts함수가 출력하려고 했던 문자열의 메모리 주소를 검사합니다.

state.memory.load함수는 메모리의 특정 위치를 읽어오는 함수인데, puts함수가 호출 될 때 esp+4에서 4바이트만큼, 즉 puts함수의 인자를 리틀엔디안으로 가져옵니다.

if state.solver.symbolic(puts_parameter):

해당 코드는 angr에게 puts_parameter가 고정된 상수인지, 입력한 값에 따라 변하는지 확인합니다.

이 값이 True일 경우 사용자 입력에 따라 주소가 달라질 수 있습니다.

is_vulnerable_expression = puts_parameter == good_job_string_address # :boolean bitvector expression

      # Finally, we test if we can satisfy the constraints of the state.
      if state.satisfiable(extra_constraints=(is_vulnerable_expression,)):
        # Before we return, let's add the constraint to the solver for real,
        # instead of just querying whether the constraint _could_ be added.
        state.add_constraints(is_vulnerable_expression)
        return True
      else:
        return False

해당 코드는 성공 제약을 설정하는 코드입니다.

good_job_string_address는 Good Job 문자열이 들어가있는 rodata영역의 주소입니다.

그 뒤 satisfiable을 통해 제약 조건이 가능한지 확인하고, 가능하면 제약조건을 추가합니다.

이게 satisfiable로 확인안하고 그냥 제약조건으로 추가하면 안되나 했는데, 불가능할 수 있는 조건을 무턱대고 add_constraints를 해버리면 모든 state가 에러가 되어 다 죽어버린다고 합니다.

 

전체 Solve는 다음과 같습니다.

import angr
import claripy
import sys

def main(argv):
  project = angr.Project("./15_angr_arbitrary_read")

  initial_state = project.factory.entry_state()

  class ReplacementScanf(angr.SimProcedure):
    def run(self, format_string, scanf0_address, scanf1_address):
      scanf0 = claripy.BVS('scanf0', 32)
      scanf1 = claripy.BVS('scanf1', 160)
     
      for char in scanf1.chop(bits=8):
        self.state.add_constraints(char >= 32, char <= 126)
      self.state.memory.store(scanf0_address, scanf0, endness=project.arch.memory_endness)
      self.state.memory.store(scanf1_address, scanf1)
      self.state.globals['solution0'] = scanf0
      self.state.globals['solution1'] = scanf1

  scanf_symbol = "__isoc99_scanf" # :string
  project.hook_symbol(scanf_symbol, ReplacementScanf())

  def check_puts(state):
    puts_parameter = state.memory.load(state.regs.esp + 4, 4, endness=project.arch.memory_endness)
    if state.solver.symbolic(puts_parameter):
      good_job_string_address = 0x4C43494B # :integer, probably hexadecimal
      is_vulnerable_expression = puts_parameter == good_job_string_address # :boolean bitvector expression
      if state.satisfiable(extra_constraints=(is_vulnerable_expression,)):
        state.add_constraints(is_vulnerable_expression)
        return True
      else:
        return False
    else: # not state.solver.symbolic(???)
      return False

  simulation = project.factory.simgr(initial_state)

  def is_successful(state):
    puts_address = project.loader.main_object.plt['puts']
    if state.addr == puts_address:
      return check_puts(state)
    else:
      return False

  simulation.explore(find=is_successful)

  if simulation.found:
    solution_state = simulation.found[0]
   
    solution = solution_state.solver.eval(solution_state.globals['solution1'], cast_to=bytes)
    print(solution)
  else:
    raise Exception('Could not find the solution')

if __name__ == '__main__':
  main(sys.argv)

 

실행결과입니다.

마지막 4바이트를 오버플로우 일으키는 것이기 때문에 앞 16글자를 a로 채웠습니다.

 

16_angr_arbitrary_write

이번엔 임의 쓰기 문제입니다.

이번 문제는 흐름이 비교적 쉽게 잡힌느거 같은데 password_buffer에 들어있는 문자열을 PASSWORD에서 GCDAHMGI로 바꾸면 됩니다.

마찬가지로 첫 키 입력은 29080829로 입력을 하고,  s에 오버플로우를 일으켜 dest에 password_buffer의 주소가 담기게 하면, strncpy에서 dest에 s의 앞글자가 입력되게 됩니다.

s에는 GCDAHMGO + aaaaaaaa + password_buffer의 주소값이 담기면 되겠네요.

 

앞부분은 거의 비슷해서 전 문제랑 비슷하게 채웠습니다.

  # In this challenge, we want to check strncpy to determine if we can control
  # both the source and the destination. It is common that we will be able to
  # control at least one of the parameters, (such as when the program copies a
  # string that it received via stdin).
  def check_strncpy(state):
   
# The stack will look as follows:
    # ...          ________________
    # esp + 15 -> /                         \
    # esp + 14 -> |     param2         |
    # esp + 13 -> |      len               |
    # esp + 12 -> \_____________/
    # esp + 11 -> /                          \
    # esp + 10 -> |     param1         |
    #  esp + 9 -> |      src                |
    #  esp + 8 -> \_____________/
    #  esp + 7 -> /                          \
    #  esp + 6 -> |     param0          |
    #  esp + 5 -> |      dest              |
    #  esp + 4 -> \_____________/
    #  esp + 3 -> /                          \
    #  esp + 2 -> |     return             |
    #  esp + 1 -> |     address         |
    #      esp ->   \_____________/
    # (!)
    strncpy_dest = ???
    strncpy_src = ???
    strncpy_len = ???

문제에서 첫 차이점인데, puts인자 가져오는 것과 비슷한 원리입니다.

strncpy가 호출될 때 인자값을 가져오는 과정입니다. 인자가 3개이므로 맞춰서 채워줍니다. 각 인자들 사이에는 4바이트 차이가 있습니다.

    if state.solver.symbolic(src_contents) and state.solver.symbolic(strncpy_dest):
      password_string = GCDAHMGI # :string
      buffer_address = 0x4C434940 # :integer, probably in hexadecimal
      does_src_hold_password = src_contents[???:???] == password_string

src_content 를 가져와서 슬라이싱하고, password_string이랑 같은지 확인하는 코드입니다.

비밀번호는 8바이트이므로 127비트~64비트까지 슬라이싱합니다.

 

전체 solve입니다.

import angr
import claripy
import sys

def main(argv):
  project = angr.Project("./16_angr_arbitrary_write")
  initial_state = project.factory.entry_state()

  class ReplacementScanf(angr.SimProcedure):
    # Hint: scanf("%u %20s")
    def run(self, format_string, scanf0_address, scanf1_address):
      # %u
      scanf0 = claripy.BVS('scanf0', 32)
     
      # %20s
      scanf1 = claripy.BVS('scanf1', 160)

      for char in scanf1.chop(bits=8):
        self.state.add_constraints(char >= 32, char <= 126)

      self.state.memory.store(scanf0_address, scanf0, endness=project.arch.memory_endness)
      self.state.memory.store(scanf1_address, scanf1)

      self.state.globals['solution0'] = scanf0
      self.state.globals['solution1'] = scanf1

  scanf_symbol = "__isoc99_scanf"  # :string
  project.hook_symbol(scanf_symbol, ReplacementScanf())
.
  def check_strncpy(state):

    strncpy_dest = state.memory.load(state.regs.esp + 4, 4, endness=project.arch.memory_endness)
    strncpy_src = state.memory.load(state.regs.esp + 8, 4, endness=project.arch.memory_endness)
    strncpy_len = state.memory.load(state.regs.esp + 12, 4, endness=project.arch.memory_endness)

    src_contents = state.memory.load(strncpy_src, 16)

    if state.solver.symbolic(src_contents) and state.solver.symbolic(strncpy_dest):
      password_string = b"GCDAHMGI" # :string
      buffer_address = 0x4C434940 # :integer, probably in hexadecimal
      does_src_hold_password = src_contents[127:64] == password_string

      does_dest_equal_buffer_address = strncpy_dest == 0x4C434940

      if state.satisfiable(extra_constraints=(does_src_hold_password, does_dest_equal_buffer_address)):
        state.add_constraints(does_src_hold_password, does_dest_equal_buffer_address)
        return True
      else:
        return False
    else: # not state.solver.symbolic(???)
      return False

  simulation = project.factory.simgr(initial_state)

  def is_successful(state):
    strncpy_address = 0x08049070
    if state.addr == strncpy_address:
      return check_strncpy(state)
    else:
      return False

  simulation.explore(find=is_successful)

  if simulation.found:
    solution_state = simulation.found[0]

    solution = solution_state.solver.eval(solution_state.globals['solution1'], cast_to=bytes)
    print(solution)
  else:
    raise Exception('Could not find the solution')

if __name__ == '__main__':
  main(sys.argv)

 

실행 결과입니다.

 

17_angr_arbitrary_jumps

드디어 마지막 문제입니다..!

마지막 문젠데 정말 코드가 너무 간결하네요

fad

함수들 목록을 보니 PRINT_GOOD이라는 함수가 보입니다.

버퍼오버플로우를 일으켜서 리턴 주소를 덮는다인 것 같은데

오랜만에 checksec을 써보니 NX만 켜져있고 다 꺼져있네요.

하지만 문제의 의도대로 하기 위해서 문제풀이 코드를 켜봤습니다.

  # Make a symbolic input that has a decent size to trigger overflow
  # (!)
  symbolic_input = claripy.BVS("input", ???)

symbolic_input에 input크기인데 넉넉하게 64바이트로 잡아주겠습니다.

# The save_unconstrained=True parameter specifies to Angr to not throw out
  # unconstrained states. Instead, it will move them to the list called
  # 'simulation.unconstrained'.  Additionally, we will be using a few stashes
  # that are not included by default, such as 'found' and 'not_needed'. You will
  # see how these are used later.
  # (!)
  simulation = project.factory.simgr(
    initial_state,
    save_unconstrained=???,
    stashes={
      'active' : [???],
      'unconstrained' : [],
      'found' : [],
      'not_needed' : []
    }
  )

여기서 unconstrained가 핵심인데, uncontstrained=True이면 angr가 다음에 실행할 코드의 주소를 모르겠는 상태 즉 사용자의 입력값에 따라 실행 흐름을 조작할 수 있는 상태를 저장해둡니다.

저희는 이 상태를 찾으면 저장해둔 뒤, 해당 상태에 저희가 원하는 함수 주소를 넣음으로써 오버플로우를 일으킬 수 있습니다.

  def has_found_solution():
    return simulation.found

해당 함수는 종료 조건 함수입니다.

이전에 썼던 explore를 찾을 수 없기 때문에 따로 함수로 만들어줘야합니다.

simulation.found는 리스트이기 때문에, 리스트가 비어있으면 False, 값이 있으면 True를 반환합니다.

  def has_unconstrained_to_check():
    return ???

 

이건 리스트에 무언가가 들어왔는지 검사하는 코드를 작성해주면 됩니다.

def has_active():
    return ???

이건 아직 실행중인 state가 남아있는지 확인합니다.

  while (has_active() or has_unconstrained_to_check()) and (not has_found_solution()):

실행중인 state가 있거나 리스트에 무언가가 들어왔으면서 아직 정답을 못찾은 상태이면 조건이 맞는동안 계속 반복합니다.

for unconstrained_state in simulation.unconstrained:
      simulation.move('unconstrained', 'found')
simulation.step()

위의 while문이 실행될 때마다 unconstrained로 분류된 state가 있는지 검사하고, 없을경우 시뮬레이션을 한 단계 진행시킵니다.(simulation.step())

if simulation.found:
    solution_state = simulation.found[0]

    # Constrain the instruction pointer to target the print_good function and
    # (!)
    solution_state.add_constraints(solution_state.regs.eip == 0x4c434954)

state를 찾으면 EIP를 저희가 원하는 주소를 넣어봅니다.

4c434954는 print_good함수의 주소입니다.

흠 금방 될 줄 알았는데, 아무리 고쳐봐도 안되는데 ㅜㅜ

로직 자체를 좀 바꿔서 수정했습니다.

전체 코드는 다음과 같습니다.

import angr
import claripy
import sys

def main(argv):
    path_to_binary = "./17_angr_arbitrary_jump"
    project = angr.Project(path_to_binary, auto_load_libs=False)

    initial_state = project.factory.entry_state(
        add_options={
            angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY,
            angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS
        }
    )

    simulation = project.factory.simgr(initial_state, save_unconstrained=True)

    solution_state = None
    target_eip = 0x4c434954

    def has_found_solution():
        return solution_state is not None

    def has_unconstrained():
        return len(simulation.unconstrained) > 0

    def has_active():
        return len(simulation.active) > 0

    while (has_active() or has_unconstrained()) and (not has_found_solution()):
        for unconstrained_state in simulation.unconstrained:
            eip = unconstrained_state.regs.eip

            if unconstrained_state.satisfiable(extra_constraints=[eip == target_eip]):
                solution_state = unconstrained_state
                solution_state.add_constraints(solution_state.regs.eip == target_eip)
                break

        simulation.drop(stash='unconstrained')
        simulation.step()

    if solution_state:
        stdin_content = solution_state.posix.stdin.content
        sym_input = stdin_content[0][0]

        for byte in sym_input.chop(bits=8):
            solution_state.add_constraints(byte >= 0x41, byte <= 0x5A)

        solution = solution_state.posix.dumps(sys.stdin.fileno())
        print(solution)
    else:
        raise Exception('Could not find the solution')

if __name__ == '__main__':
    main(sys.argv)

실행 결과입니다.

 

이상으로 길고 길었던 angr CTF 포스팅을 마치겠습니다.

일주일동안 문제만 푼 것 같네요.

다음 포스팅에는 아마 angr를 사용해서 CTF 문제들을 풀어보는 연습을 좀 해보고, 해당 툴을 더 잘 사용하기 위한 방법들을 좀 고민해볼 것 같습니다.

읽어주셔서 감사합니다~