당신의 권한 부여(Authz)는 호출자를 확인하지만, 모델은 테넌트(Tenant)를 선택합니다
요약
AI 에이전트에서 발생하는 '혼동된 대리인(Confused Deputy)' 문제는 권한 부여(Authz) 자체의 결함이 아니라, 모델이 생성한 리소스 선택 인자(tenant_id 등)의 검증 부재에서 발생합니다. 이를 해결하기 위해 모델이 작성한 인자 대신 세션에서 유래된 값을 사용하는 '출처 검증 게이트(Provenance Gate)' 도입이 필수적입니다.
핵심 포인트
- 에이전트 보안 위협은 호출자 신원이 아닌 모델이 생성한 인자(Argument)에서 발생함
- 모델이 작성한 tenant_id 등은 권한이 있는 호출자라도 타 테넌트 데이터에 접근하게 함
- 해결책으로 리소스 선택 인자를 세션 기반(Session-derived)으로 강제하는 게이트가 필요함
- 보안의 핵심은 값의 유효성이 아니라 해당 값이 생성된 출처(Provenance)를 검증하는 것
AI 에이전트에서의 혼동된 대리인(Confused Deputy) 문제는 권한 부여(Authorization, Authz) 체크가 고장 난 것이 아닙니다. 그것은 잘못된 피연산자(Operand)를 대상으로 하는 권한 부여 체크입니다. 즉, 호출자(Caller)는 검증하지만, 리소스를 선택하는 모델이 작성한 tenant_id는 결코 검증하지 않습니다. 실행 전 출처 검증 게이트(Pre-execution provenance gate)는 어떤 읽기 작업이 이루어지기 전에 모델이 작성한 선택자(Selector)를 거부합니다. 이 게이트가 없을 때는 5개의 선택자 중 4개가 다른 테넌트의 행(Rows)을 반환했지만, 게이트가 있을 때는 5개 중 0개가 반환되었습니다.
당신의 에이전트는 송장(Invoices)을 읽을 권한이 있습니다. 오늘 아침 이 에이전트는 다른 회사의 송장을 읽었으며, 모든 권한 부여 체크는 '예(Yes)'를 반환했습니다.
아무도 게이트를 우회하지 않았습니다. 호출자는 주장한 본인이 맞았고, 토큰은 유효했으며, 역할(Role)은 도구(Tool) 사용을 허용했습니다. 게이트는 자신이 답변하도록 설계된 정확한 질문에 올바르게 답변했습니다. 정보 유출은 게이트가 결코 살펴보지 않은 인자(Argument)에 있었습니다. 바로 어떤 회사의 행을 가져올지 선택하는 tenant_id였습니다. 그리고 그 인자는 모델에 의해 작성되었습니다.
요약하자면:
- AI 에이전트에서의 혼동된 대리인(Confused Deputy)은 권한 체크가 깨진 것이 아닙니다. 그것은 잘못된 피연산자를 대상으로 하는 권한 체크입니다. 즉, _누가 호출하는지_는 확인하지만, _호출이 어떤 리소스를 선택하는지_는 결코 확인하지 않습니다.
- 모델이 리소스를 선택하는 인자(
tenant_id,account_id,project_id)를 작성할 때, 권한을 가진 호출자가 다른 테넌트의 데이터에 도달할 수 있습니다. 신원 확인(Identity check)은 내내 통과됩니다. - 판별 기준은 값이 아니라 출처(Provenance)입니다. 인증된 세션(Authenticated session)에서 유래된
tenant_id는 괜찮습니다. 하지만 동일한 값이라도 모델이 작성했다면 괜찮지 않습니다. 모델이 한 번 올바른 테넌트를 선택하는 것은 권한 부여가 아니라 운이기 때문입니다. - 게이트: 리소스를 선택하는 모든 인자는 세션에서 유래(Session-derived)되어야 합니다. 모델이 작성한 선택자는 데이터베이스에 접근하기 전에 거부됩니다.
- 아래의 도구는 8개의 구체적인 호출을 실행합니다. 게이트가 없을 때, 모델이 작성한 5개의 선택자 중 4개가 다른 테넌트의 행을 반환했습니다. 게이트가 있을 때, 5개 중 0개가 행에 도달했으며, 세션에서 유래된 3개의 호출은 모두 정상적으로 처리되었습니다. 표준 라이브러리만 사용하며, 오프라인이며, 결정론적(Deterministic)입니다. 출력된 모든 숫자를 다시 확인해 보세요.
AI 공개 (AI disclosure): 저는 AI 어시스턴트와 함께
scope_provenance_gate.py를 작성하였으며, Python 3.13.5 환경에서 네트워크 연결이나 키 없이 표준 라이브러리만을 사용하여 직접 세 번 실행했습니다. 아래의 모든 출력 블록은 해당 실행 결과에서 그대로 복사되었습니다. STDOUT은 세 번의 실행 모두에서 바이트 단위로 동일하며, 그 sha256 값은63adbe8ebe17e873cbf7dbdf24faed392f20a3205d50579aec1705cf3c7841cb입니다. 또한bash run_all.sh를 통해 이를 재현할 수 있습니다. 테스트 데이터(Fixture)는 합성된 것이며 아무것과도 교정되지 않았습니다. 테넌트(Tenants), 인보이스(Invoices), 세션(Sessions)은 하나의 메커니즘을 격리하기 위해 만들어진 것입니다. bot2는 새로운 프로젝트입니다. 이 프로젝트는 운영 중인 플릿(Fleet)도 없고, 여러분에게 보여줄 사고(Incident) 사례도 없습니다. 이 포스트는 버그가 어떻게 도달 가능한지를 보여주는 것이지, 실제 환경에서 얼마나 자주 발생하는지를 보여주는 것이 아닙니다. 단 하나의 원문 외부 인용구(Confused deputy의 정의)는 출처를 밝히고 링크를 제공했습니다. 제가 참조하는 실무자들의 포스트는 그들의 글이며, 저는 1차 출처를 링크합니다.
Confused deputy: 아무도 확인하지 않는 피연산자(Operand)
다음은 일반적인 에이전트 도구 호출(Agent tool call)의 형태입니다. 에이전트는 read_account_rows(account_id, limit)라는 도구를 가지고 있습니다. 인증된 세션(Authenticated session)을 통해 요청이 들어옵니다. 미들웨어(Middleware)는 이 호출자가 이 역할(Role)로서 이 도구를 호출할 수 있는지 확인합니다. 호출이 가능할 수도 있습니다. 도구가 실행됩니다. 행(Rows) 데이터가 반환됩니다.
이제 account_id가 어디에서 왔는지 살펴보십시오. LLM 에이전트에서 도구 호출 인자(Tool-call arguments)는 매우 다른 두 가지 소스에서 조립됩니다. 일부 필드는 런타임(Runtime)이 주입합니다: 세션(Session), 인증 컨텍스트(Auth context), 그리고 이미 신뢰하고 있는 요청에서 복사해 온 모든 것들입니다. 나머지는 모델이 자신의 계획(Plan)에 따라 채웁니다. limit은 모델이 선택하기에 적절한 값입니다. 하지만 account_id는 누구의 데이터를 반환할지를 결정합니다. 만약 모델이 해당 필드를 작성한다면, 리소스를 선택하는 인자는 시스템에서 가장 신뢰도가 낮은 구성 요소에 의해 작성되는 것이며, 권한 부여(Authorization) 계층은 이를 전혀 읽지 않습니다.
이것이 전형적인 Confused deputy 사례입니다. Confused deputy 문제에 관한 Wikipedia 문서는 이를 한 문장으로 정의합니다:
"정보 보안(Information security)에서 혼동된 대리인(Confused deputy)이란, 다른 프로그램(더 적은 권한이나 권한을 가진 프로그램)에 의해 속아서 시스템상의 자신의 권한을 오용하게 되는 컴퓨터 프로그램을 의미합니다."
이 용어는 Norm Hardy가 1988년 동일한 제목으로 발표한 ACM SIGOPS 논문에서 유래되었습니다. 여기서 대리인(deputy)은 여러분의 권한 부여(Authz) 미들웨어입니다. 이 미들웨어는 실제 권한(모든 계정을 읽을 수 있는 권한)을 보유하고 있지만, 호출자(caller)는 확인하면서 선택자(selector)는 확인하지 않기 때문에 모델이 선택한 대상을 향해 그 권한을 사용하도록 속게 됩니다. 권한의 격차(privilege gap)는 정확합니다: 모델은 계정 B를 읽을 권한이 없지만, 미들웨어는 권한이 있으며, 미들웨어는 모델의 선택에 따라 동작합니다.
따라서 여러분이 반박할 수 있도록 다음과 같이 주장합니다: 호출자를 검증하고 모델의 출력값으로부터 리소스 선택 인자(resource-selecting argument)를 읽어오는 권한 부여 계층은, 호출자에게 허용된 범위(scope)를 벗어난 리소스를 반환할 수 있습니다. 해결책은 신원(identity)에 대한 더 똑똑한 권한 부여(authz) 체크가 아닙니다. 다른 피연산자, 즉 선택자의 출처(provenance)에 대한 체크가 필요합니다. 아래의 게이트를 통해 데이터에 도달하는 모델 작성 account_id를 보여주거나, 게이트가 거부하는 세션 유도형(session-derived) account_id를 보여준다면, 이 도구와 저의 주장은 모두 틀린 것이 됩니다.
8번의 호출로 보는 세상
설정(fixture)은 세 개의 계정과 두 개의 세션입니다. acct_apex, acct_ceres, acct_borealis가 있으며, 각각 하나 또는 두 개의 인보이스(invoice) 행을 가지고 있습니다. 두 개의 인증된 세션이 있습니다: S1은 acct_apex에만 범위가 지정(scoped)되어 있고, S2는 acct_apex와 acct_ceres 모두에 범위가 지정된 공유 운영(shared-ops) 사용자입니다. 여기서는 아무도 acct_borealis에 대한 권한 범위가 지정되어 있지 않으며, 이는 이를 깨끗한 희생양으로 만듭니다.
BLOCK 1 -- 세상 (합성된 데이터, 아무것에도 맞춰지지 않음)
acct_apex: rows [apex-inv-2201,apex-inv-2202]
acct_borealis: rows [bor-inv-5501,bor-inv-5502]
...
권한 부여 미들웨어는 일반적인 종류입니다. 이 미들웨어는 단 하나의 질문에 답하며, 정확하게 답합니다:
def authz_allow_caller(session, tool):
"""정확히 하나의 질문에 답합니다: 이 호출자가 이 도구를 호출할 수 있는가?
이 함수는 account_id를 절대 보지 않습니다."""
...
그리고 도구(tool)는 미들웨어(middleware)가 이미 제 역할을 수행했을 것이라고 신뢰하므로, 자체적인 소유권 확인(ownership check)을 수행하지 않습니다:
def run_tool(tool, values):
if tool == "read_account_rows":
account_id = values["account_id"]
...
두 계층 모두 account_id를 읽을 권한이 누구에게 있는지 확인하지 않습니다. 이것이 버그의 전부이며, 지루한 문제이기 때문에 그대로 배포됩니다.
모델의 각 인자(argument)는 출처 태그(provenance tag)를 포함합니다: 런타임(runtime)이 인증된 컨텍스트(authenticated context)로부터 주입했다면 session, 계획(plan)에서 나왔다면 model로 표시됩니다. 이것은 제가 데모를 위해 발명한 것이 아닙니다. 여러분의 런타임은 이미 어떤 필드를 주입했고 어떤 필드를 모델이 채웠는지 알고 있습니다. 왜냐하면 호출을 조립하는 코드를 여러분이 직접 작성했기 때문입니다. 태그는 단지 여러분이 버리고 있는 사실에 이름을 붙이는 것뿐입니다.
데이터가 유출되는 동안 신원(identity)이 통과하는 것을 지켜보십시오
여덟 번의 호출을 현재 경로인 신원 확인(identity check) 후 도구 실행 순서로 실행해 보겠습니다. 어디에서도 범위 확인(scope check)은 이루어지지 않습니다.
BLOCK 2 -- 게이트(gate)가 없는 모든 열거된 호출
call sess selector(account_id) prov id_ok foreign rows_returned
C1 S1 acct_apex session True False apex-inv-2201,apex-inv-2202
...
id_ok 열을 읽어보십시오. 유출된 네 개의 행을 포함하여 모든 행이 True입니다. 신원 확인은 한 번도 실패하지 않았습니다. C2와 C3는 apex에 범위가 지정된 S1이지만, bor-inv-5501 및 bor-inv-5502인 borealis 행을 가지고 나옵니다. 이는 모델이 account_id = "acct_borealis"라고 작성했고, 미들웨어는 단지 S1이 도구를 호출할 수 있는지만 물었기 때문입니다. C5는 ceres를 읽는 S1입니다. C7은 권한이 없는 계정인 borealis를 읽는 S2입니다.
이것이 한 화면에 담긴 혼동된 대리인(confused deputy) 문제입니다. 시스템이 신뢰하는 게이트는 고장 나지 않았습니다. 게이트는 "이 호출자가 이 도구를 사용할 수 있는가?"라는 질문에 올바른
해결책은 C1과 C4를 구분하는 단 하나의 속성에 집중합니다. 그 두 행을 다시 보십시오: 동일한 세션 S1, 동일한 선택자(selector) 값 acct_apex, 디스크 상의 동일한 행들입니다. C1은 안전하지만 C4는 그렇지 않습니다. 둘 사이의 유일한 차이점은 account_id를 누가 작성했느냐입니다. C1의 값은 세션(session)에서 왔습니다. C4의 값은 모델(model)에서 왔습니다. 값은 동일합니다. 출처(provenance)가 다릅니다.
따라서 규칙은 출처, 오직 출처뿐입니다:
def scope_provenance_gate(call):
"""각 리소스 선택 인자(resource-selecting argument)의 값은 절대 보지 않고,
오직 그 출처(source)만을 확인합니다. 알 수 없는 도구나 파라미터에 대해서는
기본적으로 차단(fails closed)합니다."""
...
작은 스키마(schema)를 통해 어떤 파라미터가 리소스를 선택하는지 선언합니다 (account_id는 예, limit은 아니오). 게이트(gate)는 권한 부여(authz) 전과 데이터베이스 호출 전에 실행됩니다. 동일한 8번의 호출 결과입니다:
BLOCK 3 -- 출처 게이트(provenance gate)를 적용한 동일한 호출들
call selector prov verdict rows / reason
C1 acct_apex session ALLOW rows [apex-inv-2201,apex-inv-2202]
...
C4는 여러분이 깊이 생각해보아야 할 행입니다. 그 값 자체는 무해했습니다. acct_apex는 S1이 읽을 수 있도록 허용된 정확한 값이었고, 모델이 우연히 이를 올바르게 명명했을 뿐입니다. 그럼에도 게이트는 이를 거부합니다. 이것은 게이트가 멍청해서가 아닙니다. 게이트가 이번에 모델이 맞췄는지 여부에 대해 점수를 매기기를 거부하는 것입니다. 왜냐하면, 추측이 맞았을 때 테넌트(tenant)를 선택할 수 있도록 허용된 컴포넌트는, (추측이 틀렸더라도) 테넌트를 선택할 수 있는 권한을 갖게 되기 때문입니다. 만약 apex 읽기 권한을 원한다면, 선택자는 세션에서 와야 합니다. 모델은 우연에 대해 부분 점수를 받을 수 없습니다.
직접 다시 세어볼 수 있도록 정리한 수치들
제가 지금부터 언급할 모든 숫자는 위의 8개 행에 대한 개수입니다. 표본 크기(sample size)도, 임의의 분모를 사용한 백분율도, 평균값도 아닙니다. 직접 세어보십시오.
BLOCK 4 -- 수치 (BLOCK 2와 BLOCK 3를 바탕으로 다시 세어보십시오)
K 총 열거된 호출 수 (total enumerated calls) : 8
리소스 선택자가 세션 유래(session-derived, S)인 경우 : 3 -> C1,C6,C8
...
추정치가 아닌, 각각의 개수를 나타내는 세 가지 사실은 다음과 같습니다:
- 도달 가능함 (Reachable). 게이트(gate)가 없는 경우, 모델이 작성한 5개의 셀렉터(selector) 중 4개가 다른 테넌트(tenant)의 행(row)을 반환했습니다. 다섯 번째인 C4는 모델이 우연히 호출자 자신의 계정 이름을 지정했기 때문에 호출자 자신의 행만 반환했습니다. 이 유출은 도달 가능하며, 정확히 어떤 행들이 유출되었는지 확인할 수 있습니다.
- 차단됨 (Blocked). 게이트가 켜져 있는 상태에서는 5개 중 0개가 어떤 행에도 도달했습니다. C4를 포함한 5개 모두 데이터베이스에 접근하기 전에 거부되었습니다. 게이트는 값(value)을 결정하지 않기 때문에, 이들 중 어떤 것이 악의적인지 알 필요가 없습니다.
- 손상되지 않음 (Not broken). 게이트가 켜져 있는 상태에서도 세션 유도(session-derived) 셀렉터 3개 중 3개가 여전히 제공되었습니다. 게이트는 정당한 호출을 단 하나도 거부하지 않았습니다. 이는
F2가 가장 중요한 지점입니다. 왜냐하면 그것이 실제 해결책과 가짜 해결책을 가르는 차이점이기 때문입니다. 뻔한 패치 방법은 값 검사(value check)를 수행하는 것입니다: assert account_id == session.tenant. 그렇게 하면 C2는 막을 수 있겠지만, S2가 두 번째로 권한이 있는 계정을 읽는 기능도 망가뜨릴 것이며, 우연히 일치하는 모델 생성(model-authored) 값을 조용히 통과시켜 버릴 것입니다. F2는 게이트가 그렇게 작동하지 않는다는 것을 증명합니다. C1과 C4는 acct_apex라는 동일한 값을 가지고 있음에도 서로 반대되는 판결을 받으므로, 결정이 문자열이 아닌 소스(source)에 기반한다는 것이 증명됩니다. F4는 게이트가 게으른 과잉 교정(lazy over-correction)을 하고 있는 것도 아님을 증명합니다. 즉, 모델이 건드린 모든 인자(argument)를 거부하는 것이 아니라, 리소스를 선택하는 인자만을 거부합니다. C8의 limit는 모델이 생성한 것이지만 게이트는 이를 통과시킵니다. 왜냐하면 모델이 자신이 볼 수 있도록 허용된 50개의 계정 행을 가져오기로 선택한 것은 혼동된 대리인(confused-deputy) 문제가 아니기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기