검증은 옳았으나, 키(Key)가 틀렸다
요약
에이전트 게이트웨이 보안 설계 시 인덱싱 키(Key) 선택의 중요성을 다룹니다. 검증 로직(술어)이 올바르더라도, 상태를 참조하는 인덱스 키가 세션 단위로 설정되면 공격자가 다른 세션을 통해 보안 규칙을 우회할 수 있음을 경고합니다.
핵심 포인트
- 보안 가드는 참조하는 상태의 인덱싱 키(Primary Key)에 따라 보안 수준이 결정됨
- 세션 기반 인덱싱은 공격자가 세션을 분리할 경우 보안 규칙을 무력화할 수 있음
- 공격의 좌표와 가드가 참조하는 메모리 좌표를 일치시키는 설계가 필수적임
- 상태 테이블 설계 시 키의 수명 주기와 모집단 범위를 신중히 고려해야 함
에이전트 게이트웨이(Agent gateway)가 하나의 시퀀스를 차단합니다: 신원 변조(identity mutation)에 이은 자격 증명 복구(credential recovery).
규칙은 간단합니다. 만약 한 세션(session)이 계정의 연락처 이메일을 변경한다면, 동일한 세션은 자격 증명 복구를 요청할 수 없습니다. 술어(predicate)는 정확합니다. 테스트도 이를 다룹니다. 영수증(Receipts)에는 이전 작업 목록, 변조 이벤트, 복구 시도, 그리고 차단 결정이 나타납니다. 검토 과정에서 가드(guard)는 견고해 보입니다. 왜냐하면 위험한 쌍(pair)이 한 곳에 나타났고, 시스템이 거부했기 때문입니다.
이제 동일한 두 호출을 두 개의 세션으로 나눕니다. 세션 1은 고객 레코드의 연락처 이메일을 변경하고 종료됩니다. 세션 2는 새로 시작하여 자격 증명 복구를 요청합니다. 이전 작업 목록은 비어 있는 상태로 반환됩니다. 동일한 주체(principal), 동일한 고객 레코드, 동일한 공격입니다. 하지만 가드는 이를 허용합니다.
술어에서 실패한 것은 아무것도 없습니다. 술어가 참조한 메모리는 session_id로 인덱싱(indexed)되어 있었던 반면, 공격은 고객 레코드로 인덱싱되어 있었기 때문입니다.
모든 런타임 가드(runtime guard)는 상태(state)에 의해 뒷받침되며, 그 상태에는 기본 키(primary key)가 있습니다. 대부분의 가드 실패는 키(key) 버그입니다. 검증 로직은 옳지만, 그것이 참조하는 메모리가 잘못된 것, 즉 세션, 특정 해결 시점, 혹은 전체 플릿(fleet)으로 인덱싱되어 있습니다. 공격은 다른 좌표(coordinate)를 통해 구성됩니다. 공격자는 술어를 전혀 건드리지 않고도, 인덱스가 볼 수 없는 좌표를 선택함으로써 승리합니다. 가드의 상태 테이블 아래에 있는 키는 보안 결정(security decision)이며, 거의 아무도 이를 명시적으로 기록하지 않습니다.
이러한 실패는 몇 가지 형태로 나타나는 경향이 있습니다. 키의 수명(lifetime)이 너무 짧아서 불변량(invariant)이 사라지기 전에 상태가 소멸하는 경우, 키가 시간에 고정되어 있어 활성 작업이 오래된 답변을 전달하는 경우, 혹은 키의 모집단(population)이 너무 넓어서 플릿 통계가 중요한 단일 바인딩(binding)을 희석시켜 버리는 경우입니다.
첫 번째 사례는 좁은 수명 주기(lifetime)의 경우입니다. 불변성(invariant)은 "이 레코드(record)의 신원 변이(identity mutation) 이후에는 자격 증명 복구(credential recovery)가 불가능하다"라고 명시합니다. 이 문장을 천천히 읽어보십시오. 위험을 수반하는 명사는 바로 레코드입니다. 세션(session)은 추적(tracing), 인증 신선도(authentication freshness), 속도 제한(rate limits), 그리고 사용자 경험(user experience)을 위해 유용한 구현 컨테이너(implementation container)입니다. 세션은 공격을 인덱싱(indexing)할 뿐, 공격을 포함(contain)하지는 않습니다.
세션 키 기반의 가드(session-keyed guard)는 더 작은 불변성을 강제합니다: "이 세션 내부의 변이 이후에는 복구가 불가능하다." 이 문장은 설계 목표와 관련이 있는 것처럼 들리며, 그 유사성이 바로 함정입니다. 세션 버전은 전체 공격을 하나의 세션 안에 넣는 모든 테스트를 통과합니다. 심지어 깨끗한 로그를 생성하기까지 합니다. 쿼리(query)가 답이 실제로 비어 있는 데이터베이스 질문을 던졌기 때문에, 증거는 완벽해 보입니다.
이것은 리뷰하기 매우 어려운 종류의 버그입니다. 누락된 사실은 존재합니다. 단지 다른 키(key) 아래에 놓여 있을 뿐입니다.
해결책은 술어(predicate)를 변경하기 전에 상태 테이블(state table)을 변경하는 것입니다. 신원 변이 이력(identity mutation history)을 레코드 신원(record identity) 아래에 저장하거나, 레코드와 깔끔하게 매핑되는 안정적인 권한 부여 객체(authorization object) 아래에 저장하십시오. 복구가 시작될 때, 해당 객체에 대한 최근의 신원 변이를 요청하십시오. 이제 쿼리는 불변성과 동일한 언어로 말하게 됩니다.
좁은 키(narrow key) 문제는 멀티 에이전트 시스템(multi-agent systems)에서 더욱 악화됩니다. 한 에이전트는 계정 유지 관리의 일부로 연락처 이메일을 업데이트합니다. 다른 에이전트는 지원의 일부로 자격 증명 복구를 처리합니다. 각 에이전트는 깨끗한 세션을 가집니다. 각 세션은 무해해 보이는 단일 작업을 수행합니다. 시퀀스 가드(sequence guard)는 시퀀스가 존재하는 행이 자신의 상태 테이블에 없기 때문에 시퀀스를 감지하지 못합니다. 객체 키 기반 이력(Object-keyed history)은 이를 즉시 감지합니다.
두 번째 형태는 시간입니다. 멀티 테넌트 정책 캐스케이드(multi-tenant policy cascade)는 부모 정책(parent policy), 워크스페이스 정책(workspace policy), 스킬 정책(skill policy), 그리고 태스크 정책(task policy)의 교집합으로서 각 워크스페이스의 유효 상한선(effective ceiling)을 계산합니다. 해결(resolve) 시점에 시스템은 현재의 상한선을 계산합니다. 스킬(skills)은 로드될 때 자신의 권한 부여(grants)를 바인딩(bind)합니다. 리졸버(resolver)는 다음 액세스 시에 이를 재평가합니다. 대시보드는 워크스페이스에 대해 깔끔한 답변을 보여줍니다.
그다음에는 한계(ceiling)가 조여옵니다.
이미 실행 중인 작업에 대해서는 모든 흥미로운 지점들이 이미 과거의 일이 되어버립니다. 이전에 시작된 태스크(task)는 자신의 수명 동안 이전의 교집합(intersection)을 유지합니다. 상주하는 폴러(resident poller)는 한 번 리졸브(resolve)되면 계속해서 유용한 작업을 수행합니다. 로드된 스킬(loaded skill)은 로드 시점에 확인한 권한(grants)을 그대로 보유합니다. "이 워크스페이스의 현재 정책은 무엇인가?"라는 대시보드의 질문은 실제로는 "마지막 리졸브 시점 기준"이라는 의미를 가진 데이터에 의해 답변됩니다.
다시 말하지만, 술어(predicate)는 정확할 수 있습니다. 리졸버(resolver)는 정확한 교집합을 계산할 수 있습니다. 문제는 리졸브된 권한(resolved grant) 아래에 있는 키(key)입니다. 만약 리졸브된 정책이 태스크(task)나 캐시된 액세스 경로(cached access path)를 키로 사용한다면, "현재의 한계(current ceiling)"는 조용히 "이 대상이 마지막으로 요청했을 때의 한계"로 변질됩니다.
그렇게 되면 권한 취소(revocation)는 소모적인 문제(drain problem)로 변합니다. 태스크가 종료되기를 기다려야 합니다. 워커(worker)를 재시작해야 합니다. 캐시를 비워야(flush) 합니다. 상주하는 컴포넌트들이 곧 다시 리졸버(resolver)를 건드리기를 바라야 합니다. 가드(guard)의 메모리에는 현재 권한과 오래된(stale) 권한을 직접적으로 구분할 방법이 없으므로, 런타임(runtime)은 시간을 간접적으로 관리해야만 합니다.
더 깔끔한 형태는 각 리졸브된 정책에 세대 카운터(generation counter)를 찍는 것입니다. 정책 권한(policy authority)은 유효한 한계(effective ceiling)가 변경될 때마다 세대(generation)를 증가시킵니다. 진행 중인 작업(in-flight work)은 자신이 리졸브했을 당시의 세대를 지니고 있으며, 모든 외부 호출(outbound call) 시에 이를 제시합니다. 호출을 받는 쪽(callee)은 해당 세대를 워크스페이스나 객체(object)의 현재 세대와 비교하여, 오래된 세대는 거부합니다. 권한 취소(revocation)는 비교(comparison)의 문제가 됩니다.
이는 새로운 런타임 동작을 추가합니다. 태스크(task)는 작업 도중에 권한을 잃을 수 있습니다. 해당 거부 경로(rejection path)는 실제적이어야 하며, 처리되고, 로그에 기록되어야 하며, 호출자(caller)에게 가시적으로 보여야 합니다. 이는 과거에 오래 지속되는 권한(long-lived grants) 뒤에 숨겨져 있던 실패들을 표면 위로 드러낼 것입니다. 그러한 불편함은 "현재의 한계(current ceiling)"라고 말하면서 실제로 '현재'를 의미하기 위해 지불해야 하는 대가입니다.
(데이터베이스 전문가들은 키 선택에 관한 논쟁을 수십 년 전에 끝냈지만, 에이전트 시스템(agent systems)은 정책 캐시(policy caches) 내부에서 그 논쟁을 다시 불러일으키는 데 성공했습니다.)
세 번째 형태는 반대 방향을 가리킵니다. 키(key)가 너무 넓습니다.
수천 개의 리스팅(listing) 전반에 걸쳐 결제 주소(payment address)를 감시하는 마켓플레이스 모니터를 가정해 봅시다. 이 시스템은 주소 변경을 기록하고, 관찰된 모든 변경 사항을 스왑 공격(swap-attack) 레이블로 격상시킵니다. 합리적으로 들립니다. 결제 주소가 변경되었고, 결제 사기(payment fraud)가 우려되니 경고를 보낸다는 논리입니다.
하지만 이 경고 스트림은 쓸모가 없습니다.
대부분의 변경은 정직한 활동입니다. 판매자(vendor)는 견적(quote)마다 새로운 주소를 생성(mint)하며, 금고(treasury)는 주소를 이전(migrate)합니다. 마이그레이션 도중에 크롤링(crawl)된 리스팅은 아무런 문제가 없음에도 외부에서는 불안정해 보일 수 있습니다. 모니터는 속도 임계값(speed thresholds)을 사용하여 규칙을 개선하려 시도합니다. 예를 들어, 10초 이내에 두 개의 주소가 나타나거나, 한 번의 크롤링 윈도우(crawl window) 내에 세 번의 변경이 발생하는 경우를 찾는 식입니다. 하지만 견적당 주소 생성(per-quote minting) 또한 매우 빠르기 때문에 오탐(false positives)은 여전히 남습니다.
플릿 뷰(fleet view)는 잘못된 키(key)를 사용하고 있습니다. 이 방식은 관찰 집단 전체에서 해당 리스팅의 주소가 변경되었는지를 묻습니다. 하지만 공격은 단일 요청 라이프사이클(request lifecycle) 내부에서 발생합니다. 즉, 이 호출자(caller)가 주소 A로 견적을 받았는데, 지금은 주소 B로 결제 서명을 요구받고 있는 상황입니다. 이 둘은 서로 다른 사실입니다. 정직한 주소 순환(rotation)과 악의적인 교체(substitution) 모두 플릿 관점에서는 주소 변동(address churn)으로 보이기 때문에, 아무리 많은 플릿 통계(fleet statistics)를 모아도 요청 내부의 바인딩(intra-request binding)에 수렴할 수 없습니다.
해결책은 클라이언트 측 고정(client-side pinning)입니다. 지출 결정 시점에 결제 주소를 바인딩(bind)하십시오. 호출자가 견적을 받을 때, 해당 주소를 요청 식별자(request identity)와 함께 저장합니다. 지출(spend)이 준비되면, 서명 중인 주소를 고정된(pinned) 주소와 비교합니다. 불일치할 경우 중단합니다.
이렇게 하면 정직한 주소 순환에 대한 오탐(false positives)은 구조적으로 제로(zero)가 됩니다. 결제자(payer)마다 주소를 생성하는 판매자는 특정 요청에 대해 특정 결제자에게 하나의 주소만 제공합니다. 이 판매자는 다른 결제자나 이후의 견적에 대해 자유롭게 주소를 순환할 수 있으며, 서명자(signer)를 대상으로 모순되는 행동을 하지 않습니다. 가드(guard)는 이제 이 판매자가 주소를 변경하는지를 묻는 대신, 이 호출자가 견적받았던 주소로 결제하도록 요구받고 있는지를 묻기 시작합니다. 두 번째 질문이 바로 불변량(invariant)입니다.
이것이 일반적인 방법입니다. 불변량(invariant)을 소리 내어 읽고 그 안의 명사를 찾으세요. 그 명사가 바로 키(Key)입니다. "결제자는 견적을 받은 주소로 결제한다"라면 키는 요청(request)입니다. "이 레코드에 변이(mutation)가 발생한 후에는 복구할 수 없다"라면 키는 레코드(record)입니다. "작업은 현재 상한선(ceiling) 하에서 실행된다"라면 키에는 현재 정책 생성(policy generation)이 포함됩니다. 왜냐하면 시간은 주장(claim)의 일부이기 때문입니다. 명사를 찾은 후에는 두 가지 질문을 던지세요.
누가 이 키를 가로지를 수 있는가? 이것이 분할 공격(split attack)입니다. 만약 가드(guard)가 세션(session)을 키로 사용한다면, 공격자가 하나의 레코드에 대해 두 개의 세션을 사용할 수 있는가? 만약 가드가 태스크(task)를 키로 사용한다면, 오래된 권한이 이후의 호출로 이어질 수 있는가? 만약 가드가 리스팅(listing)을 키로 사용한다면, 부정 행위가 개별 요청(individual request)으로 옮겨갈 수 있는가?
누가 그 안에 숨을 수 있는가? 이것이 평균화 실패(averaging failure)입니다. 만약 가드가 플릿(fleet, 집합)을 감시한다면, 플릿의 동작이 정상적으로 유지되는 동안 공격자가 단일 호출자의 생명주기(lifecycle) 내부에서 작동할 수 있는가? 만약 가드가 워크스페이스(workspace)를 감시한다면, 워크스페이스의 집계(aggregate)가 매끄럽게 지워버리는 민감한 이력을 단일 객체(object)가 보유할 수 있는가?
이 질문들은 기계적이며, 바로 그 점이 이 질문들을 유용하게 만듭니다. 이 질문들은 설계 검토(design review)의 방향을 서술어의 미학(predicate aesthetics)에서 상태 테이블(state table)로 강제 전환시킵니다. 어떤 행(row)이 존재하는가? 어떤 열(column)이 그것들을 식별하는가? 그것들은 얼마나 오래 지속되는가? 어떤 서비스들이 식별자(identifier)에 대해 합의하는가? 가드가 "이전(previous)", "현재(current)", "동일한(same)", 또는 "이것(this)"이라고 말할 때 실제로 무엇을 쿼리(query)하는가?
어려운 점은 더 나은 키가 더 많은 비용을 발생시킨다는 것입니다. 객체 기반 키(Object-keyed) 이력은 영구 저장소(durable store), 보존 정책(retention policy), 그리고 병합(merges), 삭제(deletes), 별칭(aliases), 마이그레이션(migrations)을 포함한 객체 정체성(object identity)에 대한 서비스 간 합의가 필요합니다. 이는 프라이버시 문제를 야기하는데, 보안 메모리(security memory)가 이제 그것을 생성한 상호작용(interaction)보다 더 오래 지속되기 때문입니다. 이 중 어느 것도 깔끔한 세션 객체(session object) 안에 들어갈 수 없습니다.
생성 검사(Generation checks)는 캐시된 권한 부여(cached grants)보다 비용이 많이 듭니다. 진행 중인 작업(In-flight work)은 이미 시작된 이후의 거부(rejection)를 처리할 수 있어야 합니다. 재시도(Retries)는 정책을 다시 해결(re-resolve)해야 합니다. 부분적인 진행(Partial progress)은 일관된 설명(consistent story)이 필요합니다. 오래된 권한 부여(stale grant)는 더 이상 이론적인 우려 사항이 아니라 실제 운영상의 결과(production outcome)가 됩니다.
클라이언트 측 피닝 (Client-side pinning)에도 비용이 따릅니다. 서명 구성 요소 (signing component)는 바인딩 (binding)을 저장할 공간이 필요하며, 쿼트 (quote)와 지출 (spend)은 공유된 요청 식별자 (request identity)를 가져야 합니다. 또한, 재시작된 결제가 실수로 오래된 주소 (stale address)를 유지하지 않도록 복구 흐름 (recovery flows)에 주의를 기울여야 합니다. 서버 측에서는 플릿 모니터 (fleet monitor)가 단독으로 문제를 해결할 수 있다는 안도 섞인 환상을 잃게 됩니다.
그러한 비용을 인정하십시오.
그다음, 트레이드오프 (trade-off)에 대해 정확해지십시오. 세션 키 방식 (Session-keying)은 결코 동일한 불변성 (invariant)을 더 저렴하게 강제하는 방법이 아니었습니다. 그것은 설계 문서 (design doc)의 문구와 우연히 일치했을 뿐인, 더 약한 불변성을 강제했을 뿐입니다. 플릿 모니터는 인구 통계적 행동 (population behavior)에 대한 실제 질문에 답했지만, 그것은 단지 다른 질문이었을 뿐입니다. 그리고 캐시된 권한 부여 (cached grant)는 가드 (guard)가 확인할 수 있는 만료 기한이 없는 과거의 정책입니다.
보안 설계는 종종 상태 (state)를 술어 (predicate) 아래에 있는 배관 (plumbing)처럼 취급합니다. 런타임 가드 (runtime guards)에게 이것은 거꾸로 된 방식입니다. 술어는 금지된 대상의 형태를 설명하며, 키 (key)는 가드가 그 형태를 충분히 오래, 충분히 신선하게, 그리고 충분히 로컬하게 기억하여 이를 차단할 수 있는지 여부를 결정합니다.
가드를 작성할 때는 술어 옆에 그 기본 키 (primary key)를 함께 작성하십시오. 키와 불변성의 명사 (noun) 사이의 불일치는 잘못된 술어와 동일한 심각도를 가진 버그입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기