취약점분석/Reversing

[Reversing] angr CTF 문제 풀이 - 1 (00 ~ 03)

poiri3r 2026. 1. 1. 21:18

안녕하세요! 오늘은 angr CTF 문제를 풀어보면서 angr에 대한 사용법을 익혀보도록 하겠습니다.

26년이 되었는데 다들 새해 복 많이 받으시고 좋은 일만 가득하시기 바랍니다.

 

먼저 angr ctf문제는 아래 주소에서 확인해볼수 있습니다.

https://github.com/jakespringer/angr_ctf

 

GitHub - jakespringer/angr_ctf

Contribute to jakespringer/angr_ctf development by creating an account on GitHub.

github.com

 

 git clone https://github.com/jakespringer/angr_ctf.git

 

git명령어로 문제 파일을 전체로 다운로드 받았습니다.

다음과 같이 총 17개의 문제들이 있습니다.

각 파일 안엔 다음과 같이 4개의 파일이 있는데, 00_angr.find.c.jinsa는 문제 원본 소스 코드입니다.

generate.py는 문제를 만들어 주는 파일입니다. generate.py를 실행하기 위해선 32비트 라이브러리 헤더 파일과 jinja2 라이브러리가 필요합니다.

apt install gcc-multilib
pip install jinja2

로 다운받아주고, 다음 명령어로 문제를 생성했습니다.

python generate.py 0 00_angr_find

이제 scaffold00.py를 보면

import angr
import sys

def main(argv):
  # Create an Angr project.
  # If you want to be able to point to the binary from the command line, you can
  # use argv[1] as the parameter. Then, you can run the script from the command
  # line as follows:
  # python ./scaffold00.py [binary]
  # (!)
  path_to_binary = ???  # :string
  project = angr.Project(path_to_binary)

  # Tell Angr where to start executing (should it start from the main()
  # function or somewhere else?) For now, use the entry_state function
  # to instruct Angr to start from the main() function.
  initial_state = project.factory.entry_state(
    add_options = { angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY,
                    angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS}
  )

  # Create a simulation manager initialized with the starting state. It provides
  # a number of useful tools to search and execute the binary.
  simulation = project.factory.simgr(initial_state)

  # Explore the binary to attempt to find the address that prints "Good Job."
  # You will have to find the address you want to find and insert it here.
  # This function will keep executing until it either finds a solution or it
  # has explored every possible path through the executable.
  # (!)
  print_good_address = ???  # :integer (probably in hexadecimal)
  simulation.explore(find=print_good_address)

  # Check that we have found a solution. The simulation.explore() method will
  # set simulation.found to a list of the states that it could find that reach
  # the instruction we asked it to search for. Remember, in Python, if a list
  # is empty, it will be evaluated as false, otherwise true.
  if simulation.found:
    # The explore method stops after it finds a single state that arrives at the
    # target address.
    solution_state = simulation.found[0]

    # Print the string that Angr wrote to stdin to follow solution_state. This
    # is our solution.
    print(solution_state.posix.dumps(sys.stdin.fileno()).decode())
  else:
    # If Angr could not find a path that reaches print_good_address, throw an
    # error. Perhaps you mistyped the print_good_address?
    raise Exception('Could not find the solution')

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

위와 같이 주석들과 저희가 채워야 하는 값들을 ???로 표시해놨습니다.

저희는 라이브러리 사용법을 알아보면서 해당 값들을 채워보고, 문제를 풀어보겠습니다.

 

00_angr_find

먼저 문제 바이너리를 간단히 분석해보겠습니다.

흔히 보이는 리버싱 기초 문제로 complex_function이라는 난독화 함수를 거쳐서 "LCILGCDA"라는 string이 나오는지 검사하는 로직입니다.

IDA나 odjdump와 같은 도구를 사용하여, good job이 출력되는 분기에서의 주소를 찾으면 됩니다.

080492E0이라는 주소에 Good job.이 출력되는걸 확인할 수 있습니다.

아까 위에 있던 scaffold00.py에서 ???가 되어있는 부분을 수정합니다.

  path_to_binary = "./00_angr_find"  # :string

path_to_binary에 문제 파일 이름을 적어주고,

  print_good_address = 0x80492E0  # :integer (probably in hexadecimal)

print_good_address에 주소값을 적어줍니다.

실행하면 다음과 같은 문자열을 획득하게 됩니다.

문제파일을 실행하고 LZCCUNLF라는 문자열을 넣어주겠습니다.

이러면 첫번째 문제는 간단하게 클리어했습니다.

01_angr_avoid

마찬가지로 문제파일을 생성해주고 IDA로 확인해보겠습니다.

두번째 파일은 분석하는게 쉽지 않더라고요.

이건 일부로 분석을 안되게 막아놓은 것 같습니다.

IDA로 뜯어서 보려면 볼 순 있을 것 같은데 문제의 의도는 아닌 것 같으니 넘기겠습니다.

함수 목록을 보면

누가봐도 수상해보이는 함수가 있습니다. 문제의 의도는 해당 함수를 피하게 하는 것일테니 함수의 주소를 알아보겠습니다.

주소는 다음과 같습니다.

이제 python 파일을 열어서 빈칸을 채워보겠습니다.

def main(argv):
  path_to_binary = "./01_angr_avoid"
  project = angr.Project(path_to_binary)
  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)
  print_good_address = 0x804926B
  will_not_succeed_address = 0x8049233

로 채워줬습니다.

여기서 주의해줘야 할 점은 good_address를 채울때

함수의 Entry Point인 8049240으로 설정을 해두면, avoid_me를 통해 maybe_good으로 접근하더라도, should_succeed값을 통과하는 부분에서 걸려서 오답이 나오기 때문에 확실하게 Good Job이 출력되는 0804926B부분 주소로 설정해야 합니다.

\

몇가지 오류와 함께 다음 문자열이 떴고 입력하니까 Good Job이 떴습니다.

02_angr_find_condition

이번 문제도 아까 0번 문제랑 매우 비슷한 형태입니다.

  def is_successful(state):
    # Dump whatever has been printed out by the binary so far into a string.
    stdout_output = state.posix.dumps(sys.stdout.fileno())

    # Return whether 'Good Job.' has been printed yet.
    # (!)
    return ???  # :boolean

  # Same as above, but this time check if the state should abort. If you return
  # False, Angr will continue to step the state. In this specific challenge, the
  # only time at which you will know you should abort is when the program prints
  # "Try again."
  def should_abort(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    return ???  # :boolean

  # Tell Angr to explore the binary and find any state that is_successful identfies
  # as a successful state by returning True.
  simulation.explore(find=is_successful, avoid=should_abort)

  if simulation.found:
    solution_state = simulation.found[0]
    print(solution_state.posix.dumps(sys.stdin.fileno()).decode())
  else:
    raise Exception('Could not find the solution')

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

채워야 하는 코드는 is_successful과 should abort라는 코드네요.

이번에는 address로 찾는게 아니기 때문에 문자열을 직접 넣을 수 있습니다. 저번 포스팅 때 썼던 방법이네요.

def is_successful(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    return b"Good Job." in stdout_output  # :boolean
 
  def should_abort(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    return b"Try again." in stdout_output  # :boolean

다음과 같이 작성해줬습니다.

이제 문제 실행 결과입니다.

비교적 쉬운 문제이네요

return b " " in stdout_output은 stdout_output에 들어간 문자열이 "" 일때 True를 반환하는 문법입니다.

 

03_angr_symbolic_registers

이번에도 complex_function을 통해 사용자 입력을 변환하는걸 역연산 하는 문제입니다.

문제가 특이한 점은

get_user_input이라는 함수에서 사용자 입력을 3개나 받는다는 점입니다.

 

import angr
import claripy
import sys

def main(argv):
  path_to_binary = argv[1]
  project = angr.Project(path_to_binary)

 
  start_address = ???  # :integer (probably hexadecimal)
  initial_state = project.factory.blank_state(
    addr=start_address,
    add_options = { angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY,
                    angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS}
  )
 
  password0_size_in_bits = ???  # :integer
  password0 = claripy.BVS('password0', password0_size_in_bits)
  ...
 
  initial_state.regs.??? = password0
  ...

  simulation = project.factory.simgr(initial_state)

  def is_successful(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    return ???

  def should_abort(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    return ???

  simulation.explore(find=is_successful, avoid=should_abort)

  if simulation.found:
    solution_state = simulation.found[0]
 
    solution0 = solution_state.solver.eval(password0)
    ...
    solution = ???  # :string
    print(solution)
  else:
    raise Exception('Could not find the solution')

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

주석에 달려있는 내용을 보면 

# Angr은 현재 scanf로 여러 가지를 읽는 것을 지원하지 않습니다 (예:
# scanf("%u %u")를 시작하려면 시뮬레이션 엔진에 지시해야 합니다
# scanf 이후의 프로그램을 호출하고 기호를 수동으로 레지스터에 삽입합니다.

# 때때로 프로그램을 시작할 위치를 지정하고 싶을 때가 있습니다. 변수는
# start_address는 심볼릭 실행 엔진의 시작 위치를 지정합니다.
# 우리는 entry_state가 아닌 blank_state를 사용하고 있다는 점에 유의하세요.

# 기호 값을 구합니다. 여러 해가 있는 경우에만 우리는
# 하나에 관심이 있으므로 eval을 사용하면 어떤 것이든 (하나만 제외하고) 반환할 수 있습니다
# 해결책. 해결하고자 하는 비트벡터를 평가합니다.

# 위에서 계산한 솔루션을 집계하고 형식화한 다음 인쇄합니다
# 전체 문자열. 정수의 순서에 주의하세요
# 예상 밑변(decimal, 팔진수, 16진수 등).
솔루션 = ? ? # :string

 

라고 되어있는데 이전문제와의 가장 핵심적인 차이를 넣어보면, 이전 문제들은 Standard input으로 사용자 입력을 받았다면, 이번 문제는 Symbolic Registers이므로, 레지스터에서 가져오는 값을 찾아야 합니다.

메인에서의 어셈블리 코드를 보면 

get_user_input에서 레지스터값을 eax, ebx, edx에 저장해서 함수를 거치고 있습니다.

먼저 이번 문제에서 달라진게 있는데

initial_state = project.factory.blank_state(

입니다.

여태까지 initial_state는 항상 main의 Entry point였는데, 이번 문제는 blank_state로 설정되어있기 때문에, 스택이나, 라이브러리를 하나도 연결이 안되어있습니다. 저희는 레지스터에 들어가는 값들을 덮어써야 하기 때문에 blank state로 설정하고, 레지스터값들을 저희가 설정하면서 값들을 알아내야 합니다.

또한 레지스터값들을 설정하기 위해서, get_user_input 이후에 start_address를 설정해야합니다.

start_address = 0x804952E  # :integer (probably hexadecimal)
  initial_state = project.factory.blank_state(
    addr=start_address,
    add_options = { angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY,
                    angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS}
  )

이렇게 설정을 해줬습니다. IDA로 봤을 때

해당 부분입니다. 그 다음은

  password0_size_in_bits = ???  # :integer
  password0 = claripy.BVS('password0', password0_size_in_bits)
  ...
 

입니다. 이건 레지스터의 비트를 구분하기 위한 함수입니다. 저희가 풀고있는 문제는 32비트이므로 32값을 넣어주겠습니다.

또한 저희가 풀고있는 문제에서 알아내야 하는 레지스터 값은 eax, ebx, edx로 총 3개입니다. 따라서 인자를 3개 만들어줍니다.

 password0_size_in_bits = 32  # :integer
  password0 = claripy.BVS('password0', password0_size_in_bits)
  password1_size_in_bits = 32  # :integer
  password1 = claripy.BVS('password1', password1_size_in_bits)
  password2_size_in_bits = 32  # :integer
  password2 = claripy.BVS('password2', password2_size_in_bits)

이렇게 설정하면 됩니다.

initial_state.regs.??? = password0
  ...

  simulation = project.factory.simgr(initial_state)

다음은 레지스터의 초기 상태를 설정해두는 코드입니다

마찬가지로 eax, ebx, edx로 3가지 넣어주면 됩니다.

성공,실패 조건은 쉬우니 바로 채우겠습니다.

 
 def is_successful(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    return b"Good Job." in stdout_output

  def should_abort(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    return b"Try again." in etr_sstdout_output

 

마지막 부분입니다.

if simulation.found:
    solution_state = simulation.found[0
    solution0 = solution_state.solver.eval(password0)
    ...
    solution = ???  # :string
    print(solution)
  else:
    raise Exception('Could not find the solution')

여기선 찾은 정답을 출력하는 부분입니다. 

  if simulation.found:
    solution_state = simulation.found[0]
 
    solution0 = solution_state.solver.eval(password0)    
    solution1 = solution_state.solver.eval(password1)
    solution2 = solution_state.solver.eval(password2)
    # Aggregate and format the solutions you computed above, and then print
    # the full string. Pay attention to the order of the integers, and the
    # expected base (decimal, octal, hexadecimal, etc).
    solution = f{solution0}, {solution1}, {solution2}  # :string
    print(solution)

soulution_state을 만족하는 숫자를 찾습니다.

돌려봤는데 안되길래 start_address를 수정해줬습니다. get_user_input에서 복귀하자마자 바로 넣으니까 되네요

성공적으로 통과했습니다. 3단계부터 갑자기 살짝 어려워진듯한 느낌이 있었는데, 정말 유용하다는 느낌이 들긴 하네요.

 

일단 글이 좀 길어진 것 같아 여기까지 작성하고 나머지 문제는 다음 포스팅에서 풀어보겠습니다.

읽어주셔서 감사합니다~