Authorize

접근 제어 결함은 대부분 하나의 세션만 보고 있으면 드러나지 않습니다. 관리자로 로그인한 상태에서 /admin/users가 목록을 돌려주면 다 정상으로 보입니다. 익명 클라이언트가 무엇을 받는지, 읽기 전용 사용자는, 옆 테넌트는 어떤지 물어본 적이 없기 때문입니다. Authorize가 그것을 묻습니다. 이미 캡처해 둔 요청을 여러 아이덴티티로 재전송하고, 각 응답을 기준선(baseline)과 비교합니다. 기준선이 받은 것을 그대로 받는 아이덴티티가 있다면 인가 우회일 가능성이 높습니다. Burp의 Autorize / Auth Analyzer, AuthMatrix에 해당하는 gori의 도구입니다.

Authorize 탭은 기본적으로 숨겨져 있습니다. 탭 바의 메뉴, 커맨드 팔레트(Ctrl-PGo to Authorize), 또는 Preferences(Ctrl-,) → Network & TabsTabs에서 표시할 수 있습니다.

아이덴티티란

gori에는 다중 세션을 다루는 별도 장치가 없습니다. 환경 변수는 키당 값이 하나이고, 세션 바인딩은 프로세스 전역 네임스페이스 하나뿐입니다. 그래서 여기서 아이덴티티는 재전송 직전에 캡처된 요청에 덧씌우는 정적 헤더 오버레이입니다. 대부분의 상황을 덮으면서, 한눈에 읽힐 만큼 단순합니다.

필드 동작
set 헤더를 upsert합니다. 같은 이름의 헤더(대소문자 무시, 원래 표기 유지) 값을 모두 교체하고, 없으면 추가합니다
remove 같은 이름의 헤더를 모두 제거합니다

"anonymous" 아이덴티티는 CookieAuthorization을 제거합니다. "admin"이나 "low-priv" 아이덴티티는 그 자리에 다른 세션의 값을 넣습니다. API라면 X-Api-Key를 설정하면 됩니다. 오버레이는 헤더 줄만 건드립니다. 요청 라인, 바디, Content-Length는 그대로이므로, 비교 대상은 실제로 같은 요청을 다른 자격증명으로 보낸 것이 됩니다.

탭에서 i를 누르면 아이덴티티 카드가 열립니다. 새 프로젝트는 두 개로 시작합니다. as-captured(오버레이 없음)와 anonymous(Cookie, Authorization 제거)입니다.

아이덴티티 카드에서
/ 아이덴티티 선택
a 추가
e / 편집
d 삭제
b 이 아이덴티티를 기준선으로 지정
esc 닫기

추가·편집 폼은 세 개의 필드로 되어 있습니다. 이름(고유해야 합니다. 같은 라벨의 행이 둘이면 결과 테이블을 읽을 수 없게 되므로 세 surface 모두 중복을 거부하며, 이름은 대소문자를 구분하지 않고 비교합니다), set할 헤더(한 줄에 Name: value 하나씩), 그리고 remove할 헤더(쉼표로 구분)입니다. 로 필드를 옮기고 로 저장합니다. 이름이 유효한 토큰이 아니거나 값에 CR/LF가 들어간 줄은 조용히 버려지지 않고, 해당 줄을 지목하면서 거부됩니다.

아이덴티티는 프로젝트에 저장되므로 gori run authorize와 MCP 도구도 여기서 설정한 집합을 기본으로 씁니다. 목록에는 헤더 이름만 표시됩니다. 세션 쿠키는 자격증명이고, 그것을 화면에 그려 두는 목록은 터미널을 흘깃 보는 누구에게나 값을 넘겨주기 때문입니다. 폼에는 값이 보입니다. 편집이란 그런 것이니까요.

아이덴티티 집합을 바꾸면 화면에 이미 있는 결과는 모두 다시 대기 상태가 됩니다. 그 판정들은 이전 집합에서 나온 것이고, 새 결과와 나란히 두면 서로 다른 두 시험을 비교하는 셈이 됩니다.

기준선

한 번의 실행에서 정확히 하나의 아이덴티티가 기준선입니다. 나머지 아이덴티티는 모두 이 응답을 기준으로 판정됩니다. 어떤 아이덴티티도 기준선을 주장하지 않으면(카드에서 b, JSON에서 "baseline":true) 캡처된 그대로의 요청이 기준선이 됩니다. 원래 세션 그대로 나가고, 나머지는 모두 같은 요청을 비추는 다른 렌즈가 됩니다.

한 번의 실행에는 기준선 외에 최소 한 개의 아이덴티티가 더 필요하며, 그렇지 않은 실행은 거부됩니다. 아이덴티티가 하나뿐이면 모든 시도가 기준선을 기준선과 비교하는 꼴이 됩니다. 실행은 끝나고 "기준선과 일치한 아이덴티티 없음"이라고 보고하겠지만, 그 말에는 아무 내용도 없습니다.

작업 흐름

  1. History에서 시험할 플로우를 선택하고 SpaceSend to Authorize를 실행합니다. Sitemap에서도 같은 동작으로 선택한 엔드포인트의 캡처 플로우를 큐에 넣습니다.
  2. Authorize 탭에서 i를 눌러 비교할 아이덴티티를 구성합니다.
  3. Ctrl-R는 아직 결과가 없는 요청을 모두 재전송하고, ⇧R는 전부 다시 실행하며, t는 커서에 있는 요청 하나만 실행합니다.
  4. 테이블을 읽습니다. 위쪽 패널은 요청당 한 행으로 종합 판정을 보여주고, 로 선택한 요청의 아이덴티티별 상세를 아래 패널에서 확인합니다.

각 아이덴티티는 자기 커넥션을 씁니다. 시도마다 핸드셰이크 비용을 치르는 의도적인 선택입니다. 커넥션 지향 인증(NTLM, Negotiate — 내부 진단에서는 흔합니다)은 메시지가 아니라 커넥션을 인증하므로, 소켓을 재사용하면 Cookie를 제거한 아이덴티티에게도 기준선의 콘텐츠가 그대로 나가고, 존재하지도 않는 우회가 만들어집니다.

Ctrl-X로 중지합니다. 중지 플래그는 요청 사이뿐 아니라 아이덴티티 사이에서도 확인되므로, 진행 중인 요청에 이미 걸린 다섯 개의 아이덴티티를 다 보내고 나서가 아니라 바로 다음 전송에서 멈춥니다. 아이덴티티 도중에 잘린 요청은 판정을 내지 않습니다. 절반만 수행된 시도가 "enforced"로 읽혀서는 안 되기 때문입니다.

판정 읽기

각 아이덴티티의 응답은 세 가지 사실 — 상태 코드, 디코딩된 바디 크기, SimHash 콘텐츠 지문 — 으로 축약되어 기준선과 비교됩니다. 지문을 만들기 전에 바디를 먼저 디코딩합니다(gzip / deflate / br / chunked). 압축된 바이트 위의 지문은 아무 의미가 없기 때문입니다.

판정 의미
baseline 이 행이 기준선입니다
different 상태 코드 클래스가 다릅니다(2xx vs 4xx vs 3xx). 접근 제어가 작동했다는 가장 분명한 신호입니다. 또는 두 리다이렉트가 서로 다른 곳을 가리킵니다
same 상태 클래스가 같고 바디도 일치합니다. SimHash 거리 3 이내 그리고 크기 차이 10% 이내. 또는 두 리다이렉트가 같은 곳을 가리킵니다
review 상태 클래스는 같은데 바디가 갈립니다. 또는 기준선 자체가 실패해 기준으로 삼을 것이 없었습니다
error 이 아이덴티티의 전송이 실패했습니다(TLS, DNS, 타임아웃, 거부). 비교된 것이 없습니다

리다이렉트끼리는 바디를 보기 전에 **Location**으로 판정합니다. 리다이렉트의 바디는 비어 있어서, 위의 세 가지 사실만으로는 모든 3xx가 서로 일치했습니다. 인증된 302 → /dashboard와 익명의 302 → /loginsame으로 나왔다는 뜻이고, 그건 접근 제어가 가장 분명하게 작동한 경우입니다. 오리진이 각 아이덴티티를 어디로 보내는가가 리다이렉트가 말하는 전부이므로 그것을 비교합니다. 문자열 완전 일치입니다. /login/login/의 차이는 gori가 대신 판단할 것이 아니라 보여줄 것이기 때문입니다.

요청 단위 행은 이를 종합합니다. 기준선이 아닌 아이덴티티 중 하나라도 same이면 BYPASS, 전부 명확히 다르면 enforced, 전송이 전부 실패했으면 error, 그 밖에는 review입니다.

네 번째 단어는 형식이 아닙니다. 아무것도 응답하지 않은 요청에는 samedifferent도 없으므로, 그 둘만으로 집계하면 enforced가 됩니다 — gori가 닿지도 못한 호스트에 대한 무결점 진단서입니다. "서버가 막아냈다"와 "응답을 받지 못했다"는 정반대의 사실이고, 모든 surface가 이를 구분해 보고합니다. 탭은 행을 error로 칠하고, gori run authorize는 그 행에 [x] error를 출력하며, MCP 판정은 unanswered_count와 함께 error입니다. 실행 전체가 아무것도 비교하지 못했다면 — 모든 요청이 게이트에 막혔거나 응답을 못 받았다면 — CLI는 요약 줄에 그렇게 적고 0이 아닌 코드로 종료합니다. 종료 코드로 판정하는 스크립트가 죽은 호스트를 막아낸 엔드포인트로 읽지 않도록.

same 판정의 두 조건은 모두 필요합니다. SimHash는 숫자와 16진 토큰을 건너뛰므로 크기가 다른 두 페이지가 가깝게 해싱될 수 있고, 크기 조건이 그것을 걸러냅니다. 10%의 여유는 실제 페이지가 요청마다 달라지는 잡음(CSRF 토큰, 타임스탬프)을 담고 있기 때문이며, 완전 일치를 요구하면 그 잡음이 전부 걸립니다.

이것은 휴리스틱이고, 이름을 일부러 중립적으로 붙였습니다. 보안적 의미는 그 아이덴티티가 어떤 권한을 가정한 것인지에 달려 있고, 그건 조작자만 알기 때문입니다. 저권한 아이덴티티의 same은 우회일 가능성이 높습니다. 두 번째 관리자 세션의 same은 정상입니다. review는 200으로 렌더된 맞춤형 "접근 거부" 페이지와 사용자별로 원래 다른 페이지가 지문으로는 구별되지 않는 지점입니다. 탭은 비교 결과를 말하고, 의도는 조작자가 읽습니다.

무엇이, 왜 건너뛰어지는가

선택 범위에는 의미 있게 재전송할 수 없는 플로우가 섞일 수 있습니다. gori는 요청한 것보다 조용히 적게 보내는 대신, 건너뛴 것을 모두 이름 붙여 보고합니다.

이유 우회 방법
no identity changes them 어떤 아이덴티티도 이 요청의 바이트를 바꾸지 않습니다. 모든 시도가 같은 것을 보내니 응답도 구조상 일치하고, 행은 same으로 읽힙니다 — 아무것도 없는 데서 만들어낸 발견입니다 이 엔드포인트가 인증에 쓰는 헤더를 set하거나 remove하는 아이덴티티를 추가하세요
not a safe method to repeat 기본적으로 GET / HEAD / OPTIONS만 재전송합니다. POST / PUT / PATCH / DELETE를 재전송하면 부수 효과가 아이덴티티 수만큼 다시 실행됩니다 --unsafe-methods(CLI), unsafe_methods:true(MCP). TUI의 수동 큐는 어떤 메서드든 받습니다. 거기서는 사람이 요청을 직접 골랐기 때문입니다
never completed 비교할 응답이 캡처에 없습니다
answered by gori gori가 이 요청을 직접 처리했습니다. 뒤에 오리진이 없습니다
outside project scope 아웃바운드 게이트가 소켓을 열기 전에 대상을 거부했습니다 --allow-unscoped(CLI), allow_unscoped:true(MCP), 또는 스코프 include 규칙 추가
already queued 같은 플로우를 두 번 지정했습니다

첫 번째 행은 새겨 둘 만합니다. 이 판단은 "이 요청에 Cookie가 있는가?" 하는 검사가 아닙니다. 그 질문은 X-Api-Key 같은 헤더로 인증하는 API를 놓쳤고, 로그인하지 않은 사이트에서는 아무 말 없이 전부 건너뛰었습니다. gori는 정확한 질문을 대신 던집니다. 어떤 아이덴티티든 이 바이트를 바꾸는가? 아니라면 비교할 것이 없고, 그 사실을 말해 줍니다.

패시브 재전송

p는 무인 재전송을 켭니다. 프록시를 통해 브라우징하는 동안, 완료되었고 스코프 안이며 안전한 메서드이고 최소 한 아이덴티티가 바꾸는 플로우가 자동으로 큐에 들어가 재전송됩니다. 기본은 꺼짐이고 다른 어떤 동작도 이것을 켜지 않습니다. 이 탭에서 사람이 키를 누르지 않아도 대상에 요청을 보내는 유일한 기능이기 때문입니다.

패시브는 수동 큐보다 더 엄격하게 통제됩니다. 스코프 include 규칙이 필요하고, 하나도 없으면 아무것도 재전송되지 않습니다. gori는 그 사실을 키를 누른 순간에 알려 줍니다. 나중에 혼자 궁금해하게 두지 않습니다. 브라우저 세션은 진단 범위가 아닌 곳에도 잔뜩 닿고, 패시브는 브라우저를 따라가기 때문입니다.

두 가지를 더 알아 두면 좋습니다. 패시브는 플로우 id가 아니라 엔드포인트(METHOD + URL)로 중복을 제거합니다. 한 세션에서 /orders를 열 번 방문해도 다시 큐에 들어가지 않지만, /orders?id=2는 별개의 행을 갖습니다. 그리고 큐는 200개로 제한됩니다. 그렇지 않으면 긴 브라우징이 브라우징 자체보다 더 많은 트래픽을 재전송하게 됩니다. 제한에 걸리면 그 사실이 표시됩니다.

탭에는 패시브가 실제로 무엇을 했는지가 계속 표시됩니다(N seen · M queued · K skipped (이유)). 그렇지 않으면 "아무 일도 일어나지 않았다"와 "아무것도 걸리지 않았다"가 똑같아 보이기 때문입니다.

동작
Ctrl-R Run pending — 아직 결과가 없는(실행한 적 없거나 전송이 실패한) 요청을 모두 재전송
⇧R Run all — 이미 결과가 있는 요청까지 포함해 전부 재전송
t 선택한 요청 하나만 실행
Ctrl-X 실행 중지
i Identities — 재전송에 사용할 아이덴티티 집합 편집
p 패시브 재전송 토글
d 선택한 요청을 큐에서 제거
/ 요청 간 이동
선택한 요청의 아이덴티티 간 이동
PgUp / PgDn 상세 패널 스크롤
SpaceX Clear — 큐와 결과를 모두 비움(메뉴 전용)

HistorySitemap에서 SpaceSend to Authorize로 요청을 이 탭에 넣습니다.

헤드리스

# 프로젝트에 저장된 아이덴티티로 캡처 플로우 두 개를 시험
gori run authorize 12 13

# 아이덴티티를 직접 지정하고, id 대신 QL 쿼리로 선택
gori run authorize --query 'host:acme.test method:GET status:200' \
  --identities identities.json --limit 20

identities.json은 탭이 저장하는 형식이자 MCP 도구가 받는 형식과 같습니다.

[{"name": "anonymous", "remove": ["Cookie", "Authorization"]},
 {"name": "low-priv",  "set": [{"name": "Cookie", "value": "session=…"}]}]

텍스트 출력은 요청당 한 블록입니다. 왼쪽 끝을 훑으며 [!] BYPASS를 찾을 수 있는 헤드라인 뒤에 아이덴티티별 행이 이어집니다.

authorizing 2 requests × 2 identities (as-captured, anonymous) = 4 requests
[!] BYPASS    #1     GET    http://127.0.0.1:8399/admin/users  · 1 of 1 identity matched the baseline
      as-captured         baseline  200  118B     —
      anonymous           same      200  118B     Δ status 200 · size same · time -434 µs

[ ] enforced  #2     GET    http://127.0.0.1:8399/orders
      as-captured         baseline  200  118B     —
      anonymous           different 403  9B       Δ status 200 → 403 · size -109 B · time +272 µs
done · 2 requests replayed · 4 sends · 1 possible bypass

건너뛴 플로우는 아무것도 보내기 전에, 플로우 단위로 먼저 보고됩니다.

skipped 1 flow · 1 no identity changes them
  #1     GET    http://acme.test/pricing  — no identity changes them

--format jsonl은 요청이 끝나는 대로 한 줄에 하나씩 흘려보내고, --format json은 버퍼링했다가 마지막에 배열 하나를 냅니다. 둘 다 와이어 크기와 함께 판정이 실제로 비교한 디코딩 후 크기를 담습니다. gzip 응답이라면 이 두 숫자는 자릿수가 달라집니다. 전체 플래그는 CLI 레퍼런스에 있습니다.

에이전트에서

네 개의 MCP 도구가 같은 엔진을 백그라운드 잡으로 구동합니다. authorize_start(job_id, 예정된 전송 수, 아이덴티티 이름, 스코프 게이트, 건너뛴 목록을 반환), authorize_status, authorize_results, authorize_stop입니다.

authorize_results는 답을 맨 앞에 놓습니다. access_control이 결과를 한 단어로 말하고(BYPASS, enforced, review, error, nothing_sent — 뒤의 둘은 비교된 것이 없다는 뜻), summary가 한 문장으로 풀어 주며, bypasses는 기준선이 아닌 아이덴티티가 기준선의 응답을 받은 요청을 페이징 없이 전부 나열합니다. 다른 것을 하나도 읽지 않는 에이전트도 발견 사항만은 받게 됩니다.

한 번의 실행은 전송 2,000건으로 제한되며, 이 상한은 플로우 × 아이덴티티를 셉니다. 500행 쿼리에 아이덴티티 넷이면 잘려서 실행되는 대신 두 요인을 모두 명시하며 시작 전에 거부됩니다. 잘린 실행은 보내지도 않은 플로우를 "enforced"로 보고하게 되기 때문입니다. 여기서는 Layer 1 스코프가 엄격합니다. 스코프 밖 대상은 allow_unscoped:true를 명시해야 합니다. 아무도 그 대상을 눈으로 확인하지 않았기 때문입니다.

아무것도 보내지 않은 실행은 증거가 아닙니다

이것은 코드가 모든 표면에서 일부러 지키는 성질입니다.

샌드박스나 exclude 규칙이 소켓을 열기도 전에 모든 전송을 거부했거나, 선택한 플로우가 전부 건너뛰어졌거나, 도중에 실행을 중지했다면, gori는 "기준선과 일치한 아이덴티티 없음"이라고 보고하지 않습니다. 아무것도 보내지 않았다고 말합니다. 기기를 떠난 적도 없는 트래픽에 안전 판정을 내리는 것은 접근 제어 시험이 실패하는 가장 나쁜 방식입니다. 오탐보다 나쁩니다. 그걸 보고 티켓을 닫을 테니까요.

진짜 enforced에도 같은 주의가 필요합니다. 그것은 시험한 아이덴티티들에 대해, 재전송한 요청들에서 접근 제어가 지켜졌다는 뜻입니다. 다른 엔드포인트, 다른 권한 경계, 모델링하지 않은 아이덴티티는 안전한 것이 아니라 시험되지 않은 것입니다.

다음 단계