엔지니어링 노트보안과 감사

토큰이 든 2,000자 다운로드 주소, 모델에게는 19자 참조만 보인다

클라우드 파일 서비스의 커넥터(MCP 서버처럼 회사가 OwlAI에 연결한 외부 서비스)는 파일을 내려받을 주소를 사전 인증 URL(presigned URL)로 돌려줍니다. 이 주소에는 살아 있는 접근 토큰이 통째로 들어 있고, 길이가 2,000자를 넘기도 합니다. 모델이 이 파일을 샌드박스로 받으려면 주소를 다음 도구 호출의 인자로 한 글자씩 옮겨 적어야 하는데, 옮겨 적다 글자가 무너지면 턴(질문 하나를 받아 답을 내기까지의 한 번)이 실패합니다. 옮겨 적는 데 성공해도 토큰은 모델 프로바이더로 가는 요청과 대화 맥락, 화면 미리보기와 턴 이벤트 로그에 평문으로 남습니다.

그래서 OwlAI는 이런 주소를 모델에게 보여 주지 않습니다. 서버가 도구 결과에서 토큰이 든 주소를 찾아 암호화해 보관하고, 그 자리에 19자짜리 참조를 넣습니다. 모델은 참조를 그대로 옮겨 적고, 파일을 받는 도구가 참조를 원래 주소로 되돌려 요청을 보냅니다. 이 글은 어떤 주소를 가리는지, 가리는 자리와 되돌리는 자리를 어떻게 맞췄는지, 참조를 누가 어디서 되돌릴 수 있는지를 다룹니다. 저장된 자격 증명을 암호화하는 금고는 「DB 백업에 따라가는 비밀번호, 봉투 암호화로 키를 따로 둔다」에서 다룹니다.

모델이 주소를 옮겨 적으면 실패와 노출이 함께 생긴다

옮겨 적기는 생각보다 자주 무너집니다. 실운영 환경에서 관측한 실패 턴 하나에서는 모델이 토큰의 서명 부분을 되풀이해 쓰다가 인자 안에 엉뚱한 혼잣말을 쏟아 냈습니다. 인자 하나를 만드는 데 16초와 1,300개가 넘는 스트리밍 조각을 썼고, 결국 턴이 실패했습니다. 토큰은 의미 없는 문자열이라 모델이 앞뒤 문맥으로 고쳐 쓸 수도 없습니다.

길이가 짧아도 노출은 남습니다. 모델이 주소를 보면 그 토큰은 모델 프로바이더로 가는 요청에 실리고, 대화 맥락에 남아 이후 턴마다 다시 나갑니다. 문서에 숨은 지시가 그 주소를 다른 곳으로 보내게 시킬 수도 있습니다(「권한은 코드로 막고, 판단은 모델에 맡긴다」). 토큰이 만료될 때까지 이 주소를 가진 사람은 누구나 그 파일을 받습니다.

흔한 대처는 둘입니다. 하나는 도구 결과에서 주소를 지우는 것입니다. 문서를 렌더링한 도구가 돌려주는 파일 바이트처럼 모델이 다시 쓸 일이 없는 값은 지워도 됩니다(「답변에서 지운 전화번호도 모델은 이미 읽었다, 도구 결과에서 가린다」). 그런데 다운로드 주소는 다음 도구 호출의 입력이라, 지우면 그 자리에서 작업이 끊깁니다. 다른 하나는 모델에게는 그대로 보여 주고 로그에서만 가리는 것입니다. 이 방법으로는 프로바이더로 나가는 토큰을 막지 못하고, 옮겨 적다 깨지는 문제도 그대로 남습니다.

그래서 주소는 서버가 들고 있고, 모델은 그 주소를 가리키는 참조만 봅니다.

가릴 주소는 길이와 자격 증명 표시로 고른다

서버가 가리는 것은 HTTPS 주소 가운데 두 종류입니다. 하나는 옮겨 적기가 위험할 만큼 긴 주소이고, 다른 하나는 서명이나 접근 토큰이 들어 있는 주소입니다. 앞의 것은 자격 증명이 없어도 옮겨 적다 깨지는 문제를 막고, 뒤의 것은 길이가 짧아도 프롬프트와 로그에 남으면 안 되는 값을 막습니다.

평범한 주소는 어느 쪽에도 걸리지 않아야 합니다. 가린 주소는 모델이 답에 출처 링크로 보여 줄 수 없고(「답의 출처는 URL 하나로 모으고, 목록은 서버가 만든다」), 모델이 주소만 보고 어느 사이트의 어떤 문서인지 알아볼 수도 없기 때문입니다. 그래서 기준은 실제 링크로 맞췄습니다. 한글 경로가 든 문서 링크나 추적 파라미터가 붙은 검색 결과 링크는 꽤 길어도 가려지지 않아야 하고, 토큰과 이름이 비슷한 평범한 파라미터가 붙은 페이지 링크도 남아야 합니다. 이런 링크가 가려지면 사용자에게 보여 줄 출처가 사라집니다.

서버는 모든 도구 결과를 한 자리에서 가린다

채팅, 플레이북, 딥 리서치, 회의는 모두 같은 에이전트 루프를 돌고, 도구 결과는 모두 같은 도구 실행부를 지납니다(「에이전트 루프는 하나만 두고, 기능별 동작은 훅으로 끼운다」). 가리기는 그 실행부에서 결과를 정리하는 단계에 있습니다. 이진 데이터를 크기 표시로 바꾼 뒤, 개인정보를 가리고 큰 결과를 파일로 떼어 두기 전에 겁니다(「답변에서 지운 전화번호도 모델은 이미 읽었다, 도구 결과에서 가린다」). 그래서 커넥터 도구든 제품에 들어 있는 도구든, 새로 붙인 도구든 결과에 담긴 주소는 같은 기준으로 가려집니다.

한 도구 결과는 여러 곳으로 갑니다. 모델 입력이 되는 구조화 결과, 화면 미리보기와 턴 이벤트 로그로 가는 텍스트, 원격 서버가 붙여 보낸 경고 문구까지 같은 단계에서 가립니다. 한 곳이라도 빠지면 토큰이 그 길로 남습니다.

MCP 서버의 응답은 원격 결과 전체가 JSON 문자열 하나로 들어오는 경우가 있습니다. 문자열에서 주소를 정규식으로만 찾아 바꾸면 그 JSON의 이스케이프를 깨뜨릴 수 있어서, 문자열이 JSON으로 읽히면 풀어서 안쪽까지 훑은 뒤 다시 직렬화합니다. 바뀐 것이 없으면 원래 문자열을 그대로 둡니다. 결과의 모양이 괜히 바뀌면 다음 라운드에서 프롬프트 캐싱을 그 지점부터 못 쓰기 때문입니다.

커넥터가 준 사전 인증 URL은 서버가 들고, 모델은 19자 참조만 옮겨 적는 흐름
그림 1. 커넥터가 준 사전 인증 URL은 서버가 들고, 모델은 19자 참조만 옮겨 적는 흐름

같은 주소가 한 결과 안에서 여러 번 나오거나, 뒤의 호출이 같은 주소를 다시 돌려주기도 합니다. 같은 사용자가 같은 주소를 다시 맡기면 서버는 이미 발급한 참조를 돌려주고 유효 시간을 늘립니다. 참조가 매번 바뀌면 모델은 앞 단계에서 본 참조를 뒤에서 쓸 수 없습니다.

주소를 받는 도구는 모두 참조를 되돌린다

가리기가 모든 도구 결과에 걸리므로, 되돌리기도 주소를 인자로 받는 모든 도구에 있어야 합니다. 되돌리기가 빠진 도구가 하나라도 있으면 그 도구는 참조 문자열을 그대로 바깥 API로 보내고, 요금만 쓴 채 실패합니다. 모델은 원래 주소를 본 적이 없으니 스스로 고칠 수도 없습니다.

지금 주소를 받는 도구는 셋입니다.

도구 참조를 받는 인자
주소에서 파일을 샌드박스로 받는 도구 받을 주소
샌드박스 파일을 주소로 올리는 도구 올릴 주소(업로드용 사전 인증 URL)
웹 본문 추출 도구 읽을 주소 목록

이 목록은 테스트 하나가 고정합니다. 테스트는 등록된 도구 스키마를 모두 훑어 주소를 받는 인자를 찾고, 그 집합이 위 목록과 다르면 실패합니다. 되돌리기가 빠지는 일은 코드 한 줄을 고칠 때보다 새 도구를 붙일 때 생깁니다. 새 도구에 주소 인자가 생기면 그 도구가 참조를 되돌리는지 확인해야 테스트가 통과합니다. 세 도구의 인자 설명에는 참조를 글자 그대로 옮기라는 안내가 있습니다. 모델은 스키마 설명으로만 그 사실을 알 수 있기 때문입니다.

되돌린 뒤의 확인은 원래 주소로 합니다. 파일을 받는 도구는 내부 주소로 가는 요청(SSRF)을 막는 검사를 참조가 아니라 실제로 받을 주소에 걸고, 리다이렉트가 일어나면 옮겨 간 주소마다 다시 겁니다. 참조를 푼 주소는 출처 확인을 통과합니다(「도구 결과에 나온 주소도 모델이 먼저 썼다면 열지 않는다」). 참조는 도구 결과에서만 만들어지기 때문입니다.

원격 서버가 요청을 거절하면서 오류 본문에 요청 주소를 그대로 되풀이하는 경우가 있습니다. 파일 받기 도구는 그 주소를 호스트 이름만 남기고 잘라서 모델에게 전합니다. 거절이 자격 증명 문제로 보이면, 참조에 함께 적어 둔 발급 커넥터 이름으로 어느 연결을 확인해야 하는지도 알립니다.

참조를 되돌리지 못하면 도구는 오류를 돌려주고, 주소를 낸 도구를 다시 불러 새 참조를 받으라고 안내합니다. 안내가 다시 부르기만 가리키는 이유는 모델이 원래 주소를 모르기 때문입니다. 주소를 지어내려 들면 처음의 옮겨 적기 문제로 돌아갑니다.

참조는 발급받은 사용자만 되돌린다

참조는 짧고 규칙적이어서 모델이 지어낼 수 있는 모양입니다. 그래서 서버는 참조를 되돌릴 때 요청한 사용자가 그 참조를 발급받은 사람인지 확인합니다. 다른 사용자의 참조는 추측으로 맞혀도 되돌아오지 않습니다.

없는 참조, 만료된 참조, 남의 참조에는 모두 같은 응답을 돌려줍니다. 셋을 나눠 알려 주면 「그 참조는 있지만 당신 것이 아니다」라는 정보가 새고, 이 정보는 남의 참조를 찾아 나가는 열거 공격의 실마리가 됩니다.

원문은 DB 한 곳에 암호화해 둔다

참조는 대화 맥락에 남아 다음 턴에 다시 쓰입니다. 사용자가 앞 질문에서 받은 파일을 다음 질문에서 샌드박스로 받으라고 할 수 있습니다. 다음 턴은 다른 워커 프로세스나 다른 서버에서 돌 수 있으므로, 서버는 원래 주소를 OwlAI의 메타데이터 DB 한 곳에 둡니다. 어느 프로세스가 되돌리기 요청을 받든 같은 답이 나옵니다. 만료도 DB의 시계로 판정해, 서버마다 시계가 조금씩 달라도 답이 갈리지 않습니다.

원래 주소는 평문으로 저장하지 않습니다. 다른 자격 증명과 같은 금고 키로 암호화해 저장합니다(「DB 백업에 따라가는 비밀번호, 봉투 암호화로 키를 따로 둔다」). 같은 사용자가 같은 주소를 이미 맡겼는지는 설치 비밀을 키로 쓴 HMAC으로 찾습니다. 평문 해시로 찾으면 DB를 읽은 사람이 후보 주소를 해시해 대조할 수 있습니다. 설치 비밀이 없으면 보관하지 않습니다. 보관소의 로그에는 주소 원문이 남지 않습니다. 보관에 실패했을 때도 예외 종류만 남기고 예외 문구는 남기지 않습니다. DB 드라이버의 예외 문구에는 쿼리에 넣은 값이 함께 실리기 때문입니다.

참조의 기본 유효 시간은 1시간이고, 설치마다 설정으로 바꿀 수 있습니다. 만료된 행은 새 참조를 발급할 때 그 사용자 몫을 지우고, 주기 작업이 설치 전체를 한 번 더 지웁니다. 보관하는 행에는 사용자별 상한과 전체 상한이 있습니다.

설계에서 고른 것

가릴 주소를 좁게 잡았습니다. 모든 주소를 가리면 기준이 단순하고 새는 것도 없지만, 검색 결과와 문서 링크를 출처 링크로 쓸 수 없고, 모델은 주소를 보고 어느 페이지를 읽을지 고를 수도 없습니다. 그래서 길이와 자격 증명 표시로 토큰이 든 주소만 고릅니다. 이 기준으로 고르지 못하는 주소를 내는 커넥터가 있으면, 관리자가 그 도구에 승인 필요나 차단을 겁니다.

원래 주소를 프로세스 메모리 대신 DB에 둡니다. 메모리에 두면 빠르고 구조도 단순하지만, 다음 턴이 다른 프로세스에서 돌면 그 턴은 참조를 모르는 것으로 보고 작업이 끊깁니다. 참조 발급은 가릴 주소가 든 새 도구 결과에서만 일어나 자주 있지 않습니다. 그래서 DB를 한 번 더 읽는 비용보다 어디서 돌든 같은 답이 나오는 쪽을 골랐습니다.

유효 시간은 1시간으로 잡았습니다. 커넥터가 돌려주는 사전 인증 URL이 대개 1시간 안팎으로 만료되므로, 그보다 오래 들고 있으면 이미 쓸 수 없는 주소를 보관하게 됩니다. 긴 작업에서 만료된 참조를 만나면 모델은 안내대로 주소를 낸 도구를 다시 부르고 새 참조로 작업을 잇습니다. 도구 호출 한 번이 늘어나는 것이 대가입니다.

보관 상한은 전체 상한 하나 대신 사용자별 상한을 먼저 둡니다. 전체 상한만 있으면 도구를 많이 부르는 한 사용자의 턴이 다른 사용자가 방금 받은 참조를 밀어내고, 그 사용자는 자기 턴 안에서 참조를 되돌리지 못합니다. 사용자별로 끊으면 영향이 그 사용자의 턴 안에 머뭅니다. 전체 상한은 사용자가 많은 설치에서 행이 끝없이 늘지 않게 받칩니다.

보관소에 쓰지 못하면 원래 주소 대신 안내 문구를 넣습니다. 원래 주소를 내보내면 작업은 이어지지만, 막으려던 옮겨 적기 실패와 평문 토큰이 그대로 돌아옵니다. 안내 문구는 주소를 낸 도구를 다시 부르라고 안내하므로, 보관소가 돌아오면 모델은 새 참조를 받아 작업을 잇습니다.

에이전트가 커넥터의 다운로드 링크를 다른 도구로 넘기는 구조라면, 먼저 도구 결과 속 사전 인증 URL이 모델 입력과 로그에 그대로 실리는지 확인해 봅니다. 실린다면 결과를 받는 한 자리에서 짧은 참조로 바꾸고, 주소를 받는 모든 도구가 그 참조를 되돌리게 만든 다음, 그 도구 목록을 테스트로 고정하면 됩니다.

어떤 문제를 해결하나요?

보안과 감사의 다른 글