FlawOpen

flawopen.com/AI Security/hermes-agent-native-signin-redirect-parser-confusion

● FlawOpen 매핑: CWE-601 · CVSS v4 8.6[1] · 높음
보안 연구 · FlawOpen

Hermes Agent 네이티브 로그인 리디렉션 파싱 취약점

Tenable은 Hermes Agent 네이티브 로그인에서 심각한 리디렉션 검증 취약점을 공개했습니다. 0.21.6 이전 버전이 영향을 받습니다. 0.21.6 수정본은 Docker/Hermes Cloud와 CLI/git 설치에 제공됐고, 10월 11일 공개된 0.21.7에는 Desktop 앱, Microsoft Store/MSIX, 설치 프로그램, Docker, Hermes Cloud도 배포 대상으로 나옵니다. 확인한 릴리스 노트에는 Termux가 언급되지 않아 해당 채널은 별도로 확인해야 합니다. Tenable은 이를 High(CVSS 4.0 8.6)로 평가했으며 CVE는 할당되지 않았습니다. CWE-601은 리디렉션 결과에 대한 편집상 분류입니다.[1], [2], [5]

💡 알기 쉬운 설명 (ELI5)

접수 담당자가 한 지도에서 주소를 확인하고 입력된 문자를 그대로 적어 다른 지도를 쓰는 배달원에게 전달한다고 생각해 보세요. 배달원은 승인되지 않은 장소로 갈 수 있습니다. Hermes는 Python 파서로 callback을 검사한 뒤 정규화되지 않은 원문을 브라우저에 넘겼고, 브라우저는 이를 다르게 해석할 수 있었습니다.[1]

핵심 개념 및 용어

Native-app callback
브라우저 기반 로그인 결과를 데스크톱 앱에 전달하는 임시 로컬 웹 주소입니다.[1], [4]
URL parser differential
두 URL 파서가 같은 입력을 다르게 해석하는 현상입니다. 한 해석을 검증한 뒤 다른 해석을 사용하면 보안 경계가 무너집니다.[1], [3]
PKCE
PKCE는 인증 코드를 로그인을 시작한 앱이 보관하는 비밀 verifier에 연결합니다.[1], [4]
Loopback address
127.0.0.1 또는 ::1처럼 같은 장치로 돌아오는 IP 주소입니다. Hermes 수정은 정규 IP 리터럴만 허용하며 임의의 원격 호스트는 허용하지 않습니다.[3], [4]

프레임워크 매핑

  • MITRE CWE에 대한 FlawOpen의 개념적 매핑입니다. 공급업체나 CVE 레코드가 이 연구 시연에 해당 약점 ID를 지정한 것은 아닙니다. CWE-601

단계별 공격 실행 흐름

단계 1

공개 네이티브 로그인 경로가 callback을 받습니다

사용자 인증 전에 authorization endpoint가 redirect URI와 PKCE challenge를 받습니다.[1]

단계 2

서버와 브라우저가 callback을 다르게 해석합니다

취약한 검증기는 Python이 파싱한 hostname을 확인하면서 원래 문자열은 그대로 보관했습니다. 모호한 URL 문법은 브라우저를 다른 authority로 보낼 수 있습니다.[1], [3]

단계 3

로그인 완료 뒤 인증 코드가 노출될 수 있습니다

피해자가 정상적으로 로그인하면 브라우저가 해석한 authority로 코드를 보낼 수 있습니다. 해당 PKCE challenge를 선택한 공격자는 코드 교환을 시도할 수 있습니다. 피해자의 로그인 과정이 필요하며 무상호작용 계정 탈취는 아닙니다.[1]

단계 4

URI를 정규화하고 배포된 빌드를 업데이트합니다

수정은 모호한 문법과 비정규 authority를 거부하고 canonical URI를 반환해 그 값을 저장합니다. 지원되는 채널에서는 0.21.7 이상으로 업데이트하세요. 확인한 0.21.7 릴리스 노트에 Termux가 언급되지 않으므로 별도로 확인해야 합니다.[2], [5], [3]

소스 코드 비교: 취약한 구현 vs 보안 패치

이 코드는 보안 경계를 보여주는 예시이며 Hermes Agent 전체 소스나 exploit이 아닙니다. 취약 예시는 “파싱된 host를 검사한 뒤 원문 입력 사용” 패턴을 요약합니다. 강화 예시는 병합된 수정의 속성, 즉 모호한 입력 거부, 정규 loopback authority만 허용, canonical URI 반환 및 동일 값 저장/사용을 설명합니다. 실제 구현과 테스트는 연결된 PR을 참조하세요.[1], [3]

취약한 구현
from urllib.parse import urlparse

def validate_callback(raw):
    parsed = urlparse(raw)
    # 기존 흐름은 파싱된 호스트를 확인한 뒤에도 원본 URL을 그대로 유지했습니다.
    if parsed.scheme == "http" and parsed.hostname in {"127.0.0.1", "::1"}:
        return raw
    raise ValueError("callback must be loopback")
보안 강화 패치
from urllib.parse import urlparse, urlunparse

LOOPBACK_HOSTS = {"127.0.0.1": "127.0.0.1", "::1": "[::1]"}

def validate_callback(raw):
    if not raw.isascii() or "\\" in raw or any(ord(c) <= 0x20 or ord(c) == 0x7f for c in raw):
        raise ValueError("callback must use canonical loopback syntax")
    try:
        parsed = urlparse(raw)
        host, port = parsed.hostname, parsed.port
    except ValueError as exc:
        raise ValueError("invalid callback") from exc
    if parsed.scheme != "http" or "#" in raw or parsed.username is not None or parsed.password is not None:
        raise ValueError("callback must be a plain loopback URL")
    authority_host = LOOPBACK_HOSTS.get(host)
    if authority_host is None:
        raise ValueError("callback host must be a loopback IP literal")
    authority = authority_host + (f":{port}" if port is not None else "")
    if parsed.netloc != authority:
        raise ValueError("callback authority is not canonical")
    return urlunparse(parsed._replace(path=parsed.path or "/"))

엔지니어링 및 시스템 보안 강화 체크리스트

출처

← AI Security Directory →