AI 해커를 만들며 배운 것

대규모 코드베이스에서 보안 취약점을 찾고 고치는 CRS 개발. 티오리의 AIxCC 경험을 통해 LLM 에이전트 설계 전략과 퍼징을 넘어선 자동화 기법을 살펴봅니다.
Xint's avatar
Aug 26, 2025
AI 해커를 만들며 배운 것

💡

이 글은 영어로 작성된 원문 블로그 글을 한국어로 번역한 것입니다. 일부 표현은 한국어 독자에게 자연스럽게 전달되도록 다듬었습니다.

AI 사이버 챌린지(AIxCC)에 참가한 팀들은 대규모 코드베이스에서 보안 취약점을 찾고, 발생시키며, 패치까지 수행할 수 있는 자율적 사이버 추론 시스템(CRS)을 구축하는 과제에 도전했습니다. 충분한 클라우드 자원과 주요 LLM API 접근 권한이 주어진 상황에서, 각 팀은 서로 다른 방식으로 이 문제를 풀어갔습니다. 가장 자연스러운 접근은 기존 자동화 기법인 퍼징으로 크래시를 찾고, LLM의 코드 작성 능력을 활용해 패치를 생성하는 것이었습니다. 이러한 ‘퍼징 우선’ 방식은 LLM의 활용 범위는 제한적이지만, 여전히 경쟁력 있는 CRS의 기반이 될 수 있습니다.

하지만 최근 LLM의 발전은 사람만이 할 수 있다고 여겨졌던 보안 연구 작업까지 자동화할 가능성을 열었습니다. 이에 저희는, LLM 에이전트가 보안 연구원의 사고 방식을 모방해 퍼징 이상의 성능을 낼 수 있을지 실험했습니다. 물론 현재의 LLM은 아직 CRS 전반에 그대로 적용하기엔 한계가 있지만, 저희는 그 약점을 보완하면서도 핵심 작업에서 신뢰할 만한 결과를 얻을 수 있는 전략들을 찾았습니다.

그 결과 티오리가 제출한 CRS는 취약점 탐지, PoV(Proof of Vulnerability) 생성, 패치 개발, 크래시 근본 원인 분석까지 LLM 에이전트를 폭넓게 활용했습니다. 이 글에서는 그러한 성능을 가능하게 한 효과적인 에이전트 설계 전략을 공유하고자 합니다.

효과적인 에이전트 만들기

일반적으로 LLM은 명확하고 효율적인 알고리즘이 없지만 사람이 직관, 휴리스틱, 추론의 조합을 통해 안정적으로 해결할 수 있는 작업에 적합합니다. 특히, 에이전트는 인스턴스별 계획과 도구 사용이 필요한 작업에서 잘 작동합니다. LLM 에이전트는 목표에 도달할 때까지 반복적으로 계획하고, 도구를 호출하고, 출력을 숙고해야 합니다. 현재의 LLM은 많은 문제 설정에서 사용 가능한 정보량에 비해 제한된 컨텍스트(그리고 더욱 제한된 유효 컨텍스트)를 가지기 때문에, 에이전트는 보통 필요한 정보만 미리 제공받습니다. 다른 모든 정보는 도구 호출로 모아야 하며, 이는 에이전트가 작동하는 환경을 변경할 수도 있고 그렇지 않을 수도 있습니다.

복잡한 작업을 처리하는 에이전트를 미로 찾기에 비유할 수 있습니다:

  • 각 단계에서, 에이전트는 여러 방향을 택할 수 있습니다.

  • 성공으로 가는 길이 여럿 있을 수 있지만, 대부분의 길은 막다른 길입니다.

  • 에이전트는 매 턴마다 특정 확률로 길을 잃을 수 (막다른 길로 들어설 수) 있습니다.

중요한 점은, 최종 목표가 이 미로의 모양과 복잡성을 결정하지 않는다는 점입니다. 프롬프트, 도구, 출력 제한도 영향을 줍니다. 따라서 에이전트 개발자로서 우리의 목표는 미로를 최대한 단순하게 해서 성공으로 가는 길의 밀도를 올리는 것입니다.

일을 나누세요

가장 효과적인 단순화는 주된 작업을 일련의 하위 작업으로 나누는 것입니다. 하위 작업을 모두 해결하면 주된 작업도 해결되지만 개별적으로는 더 단순하게 해결할 수 있습니다. 각 하위 작업을 위한 에이전트를 만들면 일반적으로 전체 작업을 더 신뢰성 있게 해결할 수 있습니다. 이것은 비유하자면 큰 미로를 작은 미로로 나누어서 에이전트가 빠질 수 있는 막다른 길을 대폭 줄이는 것과 같습니다. 이렇게 일을 나누면 유효한 “지름길”을 놓칠 수 있지만, 신뢰성이 상당히 향상되기 때문에 보통 그럴 가치가 있습니다.

더 나아가 주된 작업을 해결하는 데 특정 하위 작업을 반복해야 한다면, 해당 작업을 위한 전용 서브에이전트를 에이전트가 사용할 수 있는 도구로 노출하는 것이 유용할 수 있습니다. 여기에는 두 가지 큰 이점이 있습니다.

  • 서브에이전트는 하위 작업에 대한 입력만 받아 좁은 목표에 집중할 수 있습니다.

  • 에이전트는 하위 작업의 입력과 출력만을 컨텍스트에서 보게 되므로 방해가 되는 프롬프트나 중간 도구 호출을 보지 않게 되어 자연스럽게 컨텍스트 압축으로 작용합니다.

사례

우리 CRS에는 취약점 설명이 주어지면 PoV를 생성하는 PovProducerAgent가 있습니다. 우리는 사람이 같은 일이 주어졌을 때 일반적으로 수행할 여러 공통된 하위 작업을 찾아냈습니다.

  • 입력 인코딩: 퍼징 하니스를 보고, 바이너리 형식을 이해하고, 의미 있는 데이터를 바이너리 입력으로 인코딩하는 파이썬 코드를 작성합니다.

  • 소스 질문: 코드베이스에 대한 질문에 답합니다. 이름 검색, 여러 소스 파일 탐색, 코드에 대한 추론이 필요할 수 있습니다.

  • PoV 디버그: 디버거를 써서 PoV 후보가 왜 버그를 발생시키지 못하는지 대답합니다.

이러한 작업을 도구로 부를 수 있는 서브에이전트로 분리함으로써, 주 에이전트의 컨텍스트를 간결하게 유지하고 목표에 집중시킬 수 있습니다. 그 목표는 지정된 취약점을 일으키는 바이너리 입력을 생성하는 것입니다.

도구를 선택하세요

에이전트 루프의 각 단계에서 LLM은 하나나 여러 개의 주어진 도구를 불러야 합니다. 미로로 비유하자면, 어떤 도구가 주어지는가가 미로의 갈림길 구조를 결정합니다. 도구는 목표에 빠르게 도달할 수 있도록 강력해야 하지만, 에이전트가 막다른 길에 빠지지 않도록 제한되어야 합니다.

CRS에서 도구는 완전히 우리 통제 아래 있는 파이썬 함수에 이름을 붙이고 문서화한 것 뿐이므로, 작업에 필요한 만큼 좁거나 넓게 만들 수 있습니다. 아래는 도구를 잘 선택하기 위한 몇 가지 전략입니다.

  • 에이전트의 입장에서 생각해 보세요: 컨텍스트와 도구의 정보만 가지고 성공으로 가는 길이 있나요? 없다면 그런 길이 있을 때까지 최소한의 도구를 추가하세요.

  • 에이전트를 많은 사례에 평가해 보세요: 길을 잃는 방법에 패턴이 있나요? 그렇다면 길을 잃지 않도록 도구를 제한하거나 올바른 길로 돌아오도록 에러를 추가하세요.

  • 에이전트가 일반적으로 어떤 단계를 따를 것으로 예상한다면, 프롬프트에 그렇게 쓰세요. 예를 들어, 에이전트가 항상 도구 A로 시작할 것으로 예상한다면, 그렇게 하라고 쓰면 됩니다.

사례

도구를 선택하는 것은 에이전트를 개발하면서 가장 시간이 많이 걸린 작업이었습니다. 이론적으로는 대부분의 에이전트는 write_file과 execute_bash와 같은 범용 도구만으로도 목표에 도달할 수 있습니다. 하지만 실제로는 더 잘 선택된 도구를 제공해야 더 빠르게 성공할 수 있고 길을 잃지 않을 수 있습니다.

SourceQuestionAgent가 좋은 예입니다. 이 에이전트는 소스 코드에 대한 임의의 자연어 질문을 대답해야 합니다. bash를 실행할 수 있다면 grep으로 검색하고 cat으로 소스 코드를 읽을 수도 있습니다. 그렇게 해도 잘 될 때도 있지만, 여러가지 문제가 있습니다:

  • 실수: 때때로 에이전트는 나쁜 결정을 내립니다. 자원을 너무 많이 소모하거나, 너무 오래 걸리는 명령을 실행할 수가 있습니다. 예를 들어, 리눅스 소스 코드에서 grep으로 main을 검색하면 9MB의 출력으로 컨텍스트가 꽉 차게 될 것입니다.

  • 컨텍스트 오염: 말이 되는 명령도 에이전트가 필요로 하는 것보다 훨씬 더 많은 정보를 출력할 수 있습니다. 그러면 귀중한 컨텍스트를 낭비하게 되고 방해가 되는 정보를 추가하게 됩니다. 예를 들어, 파일에서 함수 하나의 정의에만 관심이 있다면, cat /path/to/file.c는 필요로 하는 것보다 훨씬 더 많은 정보를 출력합니다.

  • 비효율성: 많은 예측 가능한 하위 목표(예: foo 함수를 호출하는 모든 함수 찾기)는 여러 개의 bash 명령을 필요로 하며, 이는 보통 에이전트 루프에서 여러 단계를 의미합니다. 이론적으로는 복잡한 bash 명령으로 한 번에 할 수도 있지만, 모델은 여러 단계에 걸쳐 수행하는 경향이 있어 실패 가능성을 높이고 컨텍스트를 낭비합니다.

에이전트의 목표가 소스 코드를 이해하는 것임을 알고 있기 때문에, 더 적절한 도구를 제공할 수 있습니다:

  • read_definition: 이름과 경로(생략 가능하며, 모호성을 해결하기 위한 것입니다)가 주어지면 소스 코드에서 해당 이름의 정의를 찾아 에이전트에게 보여줍니다.

  • find_references: 주어진 이름을 참조하는 소스 코드의 모든 줄을 찾고 각각에 대한 추가 정보를 제공합니다: source_file, line_number, enclosing_definition.

  • read_source: 경로와 줄 번호가 주어지면 해당 줄 주변의 적은 수(~50)의 줄을 읽어옵니다. 이 도구의 문서에서는 read_definition과 같은 다른 더 적절한 도구가 있으면 이 도구를 사용하지 않도록 권장합니다.

이러한 도구는 소스 코드를 미리 색인한 데이터베이스 위에 구현되어 있으며 (clang AST, joern, gtags 등을 사용합니다) 에이전트가 위험한 길에 들지 않도록 하는 많은 안전장치를 가지고 있습니다:

  • read_definition이 모호한 이름을 받으면 LLM이 다음 쿼리에서 모호성을 해결하는데 필요한 충분한 정보를 포함한 오류를 반환합니다.

  • find_references가 데이터베이스에서 주어진 이름을 찾지 못하면 ripgrep을 사용한 문자열 검색으로 대신합니다.

  • find_references가 너무 많은 참조를 찾으면 에이전트가 쿼리를 개선하도록 권장하는 오류를 반환합니다.

복잡한 출력

에이전트 개발의 미묘한 부분 중 하나는 에이전트로부터 최종 출력을 얻어내는 방법을 결정하는 것입니다. 어떤 작업은 원하는 출력이 간단해서 도구 호출 인자나 에이전트의 마지막 메시지에서 가져오면 됩니다. 다른 경우에는 원하는 출력이 복잡합니다. 예를 들어, 여러 필드를 갖는 객체 리스트를 원할 수 있습니다. 이러한 더 복잡한 경우를 위해 에이전트로부터 상당히 일관되게 결과를 얻어내는 두 가지 다른 방법을 찾아냈습니다.

  • XML 태그: 프론티어 모델은 임의의 유사 XML 태그를 꽤 잘 이해하는 것으로 보입니다. 그러므로 중첩된 XML 태그로 답변을 출력하도록 지시할 수 있습니다. 이를 퍼지하게 파싱하고 검증합니다. 에이전트에게 원하는 스키마를 미리 주고, 파싱하거나 검증할 때 발생한 오류를 반환합니다.

  • terminate 도구: 에이전트를 종료하기 위한 전용 도구를 추가하며, 도구 호출 인자가 에이전트의 출력이 됩니다. 이는 tool_choice=required를 사용할 때 특히 유용합니다. (다음 섹션 참조)

두 경우 모두 출력 스키마가 프롬프트의 일부이므로 처음부터 에이전트의 동작에 영향을 줄 수 있습니다. 따라서 실제로 살펴볼 생각은 없지만 에이전트가 원하는 출력의 품질을 개선하는 데 도움이 되는 고려 사항을 고려하게 하는 필드를 스키마에 포함시키는 것이 유용할 수 있습니다.

구조화된 출력을 사용하면 또한 에이전트 평가가 더 간단해집니다. 각 테스트 케이스에 대해 필드가 기대한 값을 가지고 있는지 확인하면 되기 때문입니다.

사례

흥미로운 예로 DiffAnalyzerAgent가 있습니다. 이 에이전트는 git diff를 분석하여 변경으로 인해 발생하는 취약점을 모두 탐지해야 합니다. diff로 인해 발생하는 취약점은 하나가 아닐 수 있으므로, 에이전트는 리스트를 출력해야 합니다. 리스트의 취약점은 여러 필드를 갖습니다.

  • function: 취약점의 근본 원인을 포함하는 함수

  • file: 위에 주어진 함수를 포함하는 파일

  • description: 취약점의 근본 원인과 어떻게 크래시를 일으키는지에 대한 간단한 설명

  • conditions: 취약점을 발생시키는데 필요한 조건 -- 하니스로부터의 데이터 흐름과 크래시를 일으키는데 필요한 입력의 제약 조건을 포함해야 합니다

conditions 필드를 다른 에이전트에 전달하긴 하지만, 이 필드의 주된 목적은 에이전트가 취약점이 하니스에서 실제로 도달할 수 있는지 고려하게 하여 오탐을 줄이는 것입니다. 아래는 세 개의 취약점을 포함하는 diff에 이 에이전트를 실행한 출력의 예입니다. 모든 취약점이 오탐 없이 탐지되었습니다.

<vuln>
  <function>ngx_http_process_black_list</function>
  <file>nginx/src/http/ngx_http_request.c</file>
  <description>Stack‐based buffer overflow due to unbounded writes into IP_buffer in ngx_http_process_black_list; buffer_index is never checked against IP_buffer’s length.</description>
  <conditions>Send an HTTP request containing a header “Black-List:” whose value consists of more than NGX_IP_LEN (16) valid IP characters (digits or dots) before a semicolon, e.g.  
  “Black-List: 11111111111111111111;”. This will overflow IP_buffer and ASan will detect the out‐of‐bounds write and crash.</conditions>
</vuln>

<vuln>
  <function>ngx_http_process_white_list</function>
  <file>nginx/src/http/ngx_http_request.c</file>
  <description>Stack‐based buffer overflow in ngx_http_process_white_list for the same reason: IP_buffer writes are unbounded.</description>
  <conditions>Send an HTTP header “White-List:” with over 16 digits/dots before the delimiter, e.g.  
  “White-List: 22222222222222222222;”. ASan will flag the overflow of IP_buffer.</conditions>
</vuln>

<vuln>
  <function>ngx_black_list_remove</function>
  <file>nginx/src/core/ngx_cycle.c</file>
  <description>Use‐after‐free: when removing the head node from cycle->black_list, the head pointer is never updated, leaving a dangling pointer. Subsequent calls to ngx_is_ip_banned dereference freed memory.</description>
  <conditions>Issue two HTTP headers in one keep‐alive connection: first  
  “Black-List: 1.2.3.4;” (inserts head), then  
  “White-List: 1.2.3.4;” (removes and frees head without updating cycle->black_list). On the next pipelined request over the same connection, ngx_is_ip_banned(rev->cycle, c) is called in ngx_http_wait_request_handler and attempts to dereference the freed head pointer, causing a UAF crash.</conditions>
</vuln>

모델에 맞추세요

이제 강력한 모델이 많이 있지만, 서로 완전한 대체품은 아닙니다. 작업에 가장 적합한 모델을 찾으려면 실험과 평가가 필요합니다. 모델을 평가할 때는 각 모델의 특징을 이해해서 프롬프트와 환경을 조정해 각 모델이 최대한의 성능을 발휘할 수 있도록 해야 합니다. 이는 주로 경험에서 오는 것이지만 더 일찍 알았더라면 싶은 팁을 소개합니다.

  • 규칙과 팁 제공: 에이전트가 어떤 실수를 자주 저지른다면, 보통 그런 행동을 금지하는 규칙을 프롬프트에 추가하여 해결할 수 있습니다. 더 나은 행동에 대한 팁을 줄 수 있으면 더욱 좋습니다. 이 방법은 Claude 모델과 가장 잘 작동하는데, Claude 모델이 지시에 잘 따르고 제약이 많은 프롬프트를 잘 처리할 수 있기 때문입니다.

  • 메시지 주입: 작업 진행 상황을 객관적으로 측정할 수 있다면, 에이전트가 막다른 길에 빠졌을 때 이를 감지해서 방법을 다시 생각해보라는 메시지를 주입할 수 있습니다. 어떤 모델이 처음 프롬프트에 주어진 규칙을 무시하는 경향이 있다면, 중간에 주입한 메시지의 규칙은 잘 따를 수도 있습니다. 작업 진행 상황을 객관적으로 측정하는 것이 쉬운 일은 아니므로, 메시지 주입이 성공 직전의 에이전트를 다른 길로 빠지게 할 위험이 있습니다. 그래서 이 방법은 조금씩만 사용했습니다.

  • 도구 호출 강제: 어떤 모델은 할 일이 더 있는데도 도구를 안정적으로 사용하는 데 어려움을 겪습니다. 대부분의 모델 제공업체에서 지원하는 tool_choice 매개변수를 써서 이러한 문제를 피할 수 있습니다. 이 방법은 OpenAI의 o 시리즈 모델과 잘 작동합니다.

사례

  • 규칙: 대부분의 에이전트 프롬프트에는 여러 <rule></rule> 블록이 있습니다. 정말로 어떤 규칙을 꼭 따르기를 원한다면 <important></important> 블록을 썼습니다.

  • 메시지 주입:

    • PovProducer가 여러 번의 시도 후에도 계속해서 실패하는 경우, 왜 실패하는지에 대한 3가지 가설을 고려해서 각각을 디버그 서브에이전트로 검증하라는 메시지를 주입합니다.

    • 일부 모델(예: o4-mini)은 때때로 종료하기를 거부합니다. 결과를 내기에 충분한 정보가 있는 것처럼 보여도 그렇습니다. 우리 작업의 경우에는 컨텍스트가 일정 길이를 넘으면 무조건 “please terminate now” 메시지를 주입하는 것이 도움이 되는 것 같았습니다.

  • tool_choice: 우리 에이전트 셋 중 하나는 tool_choice=required로 도구 호출을 강제하고 terminate 도구를 추가했습니다. 이는 도구 호출에 어려움을 겪는 작은 모델을 사용할 때 가장 도움이 되었습니다. 어떤 경우 작은 모델은 도구 호출과 지어낸 출력을 환각하는데 이러면 에이전트가 거의 실패할 수밖에 없습니다.

결론

개발을 마무리할 즈음, 저희는 LLM 에이전트가 사람 보안 연구자가 취약점을 찾고 수정하는 과정을 얼마나 잘 따라갈 수 있는지에 놀랐습니다. 결과물에 대해 자부심을 느끼지만, 동시에 LLM을 보안 자동화에 활용할 수 있는 가능성은 여전히 훨씬 더 크다고 생각합니다. 더 나아가, LLM 에이전트는 보안뿐만 아니라 다양한 복잡한 문제 해결에도 중요한 역할을 하게 될 것입니다.

LLM 자체의 성능은 앞으로도 빠르게 개선되겠지만, 가까운 미래에는 에이전트 설계와 개발 전략만으로도 성능을 크게 끌어올릴 수 있습니다. 이러한 발전은 AI가 수행할 수 있는 작업의 경계를 계속 확장하게 될 것입니다.

Share article
티오리한국 뉴스레터를 구독하고
최신 보안 인사이트를 바로 받아보세요.

Theori © 2025 All rights reserved.