Tyojong

[Amazon Q Developer] CVE-2026-12958 본문

AI/AI Agent n-day

[Amazon Q Developer] CVE-2026-12958

Tyojong 2026. 7. 28. 18:27

개요

대상 제품: Language Servers for AWS (1.69.0 미만 버전)
취약점 유형: CWE-61 (UNIX Symbolic Link Following)
위험도: High (CVSS v3.1: 7.8 / CVSS v4.0: 8.5)

 

에이전트가 프로젝트 워크스페이스에 존재하는 심볼릭 링크 파일을 일반 파일로 오인해 승인 없이 수정하고, 그 결과 링크가 가리키는 워크스페이스 외부의 민감 파일까지 변경할 수 있는 취약점이다.

 

공격자가 /home/user/.ssh/authorized_keys파일로 심볼릭 링크를 설정한 악성파일이 들어간 프로젝트를 깃허브에 올리면 사용자가 검토없이 agent에게 "이 프로젝트 읽어보고 환결설정 해줘"등의 프롬프트를 주었을 때 실제로 /home/user/.ssh/authorized_keys 경로의 파일을 수정하게 되지만 워크스페이스에 있는 정상적인 파일이라고 인식하여 공격자의 ssh키를 추가하는 등의 시나리오가 가능해진다.

 

코드 분석

/workspace/project_settings.json → /home/user/.ssh/authorized_keys로 심링크가 설정된 파일을 사용자가 다운받아

{
    command: "create",
    path: "/workspace/project_settings.json",
    fileText: "ssh-ed25519 AAAA... attacker"
}

에이전트가 다음과 같은 요청을 만들었다고 가정해보면 파일 쓰기 도구(fsWrite)를 호출하게 된다.

 

파일을 쓰기 위해서는 사용자의 승인을 받아야한다.

https://github.com/aws/language-servers/blob/b15d04e05d7248864c72f4209c4a19baee9b7dda%5E/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsWrite.ts#L97-L102

해당 코드에서 requiresPathAcceptance 를 통해 사용자의 승인 여부를 가져오게된다.

 

 

requiresPathAcceptance의 코드를 확인해보자

https://github.com/aws/language-servers/blob/b15d04e05d7248864c72f4209c4a19baee9b7dda%5E/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts#L121-L150

 

 

/workspace/project_settings.json → /home/user/.ssh/authorized_keys 파일에 대해 처리하기 때문에

        try {
            canonicalPath = await fs.promises.realpath(inputPath)
        }

에서 inputPath에 /workspace/project_settings.json 이 들어가고 realpath로 인해 canonicalPath가 /home/user/.ssh/authorized_keys로 들어간다.

typescript에서 realpath함수는 디렉토리의 실제 절대 경로(심볼릭 링크가 해결된 표준 경로)를 구하는 함수이다.

 

 

agent 사용자 pc에 /home/user/.ssh/authorized_keys 경로 파일이 존재한다면 위 코드가 실행되지만
해당 파일이 존재하지 않을 경우 ENOENT에러가 발생해 아래 코드가 실행된다.

          catch (err: any) {
            if (err && err.code === 'ENOENT') {
                const parent = path.dirname(inputPath)
                try {
                    const realParent = await fs.promises.realpath(parent)
                    canonicalPath = path.join(realParent, path.basename(inputPath))
                } catch {
                    canonicalPath = path.resolve(inputPath)
                }
            } else {
                canonicalPath = path.resolve(inputPath)
            }
        }

 

inputPath에 /workspace/project_settings.json가 들어가기 때문에

const parent = path.dirname(inputPath) 에서 parent는 /workspace가 설정된다.

 

                try {
                    const realParent = await fs.promises.realpath(parent)
                    canonicalPath = path.join(realParent, path.basename(inputPath))
                } catch {
                    canonicalPath = path.resolve(inputPath)
                }

심볼릭 링크는 project_settings.json파일에 걸려있기 때문에 realParent는 /workspace 그대로 설정된다.

inputPath가 basename함수를 거치면 project_settings.json이 나오기 때문에

결국에는 join함수로 인해 canonicalPath가 /workspace/project_settings.json으로 설정된다.

 

 

즉, 피해자 pc에 심볼릭 링크의 최종 대상 파일이 존재하면
canonicalPath가 /home/user/.ssh/authorized_keys로 설정되지만
파일이 존재하지 않으면
canonicalPath가 /workspace/project_settings.json로 설정된다.

 

 

https://github.com/aws/language-servers/blob/b15d04e05d7248864c72f4209c4a19baee9b7dda%5E/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts#L165-L172

위 과정 후 동일한 파일 아래 코드를 더 확인해보면

 

        const isInWs = workspaceUtils.isInWorkspace(workspaceFolders, canonicalPath)

canonicalPath가 위 과정을 거쳐 isInWorkspace(["/workspace"], "/workspace/project_settings.json") 이 만들어진다.

때문에 isInWs는 true로 설정이된다.

isInWorkspace 함수를 살펴보고 싶다면 아래 링크 참고

https://github.com/aws/language-servers/blob/abb1096b27099654b003571508bcb65ab0015e22/core/aws-lsp-core/src/util/workspaceUtils.ts#L128-#L150

 

 

        if (isInWs) {
            return { requiresAcceptance: false }
        }

isInWs가 true이므로 return { requiresAcceptance: false }가 반환된다
requiresAcceptance: false는 "워크 스페이스 내부의 파일이므로 별도의 사용자 승인이 필요하지 않다"라는 의미이다.

 

 

https://github.com/aws/language-servers/blob/b15d04e05d7248864c72f4209c4a19baee9b7dda%5E/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsWrite.ts#L67-L79

승인 검사를 통과하면 invoke() 를 호출한다.

 

        const sanitizedPath = sanitize(params.path)

여기서 sanitize() 가 호출되지만 일반적인 문자열 경로 정리와 심볼링 링크의 실제 대상 해석은 다른 문제이기 때문에 무관하다.

 

        switch (params.command) {
            case 'create':
                await this.handleCreate(params, sanitizedPath)
                content = 'File created successfully'
                break
            case 'append':
                await this.handleAppend(params, sanitizedPath)
                content = 'File appended successfully'
                break
        }

이후 handleCreate()가 호출된다.

 

https://github.com/aws/language-servers/blob/b15d04e05d7248864c72f4209c4a19baee9b7dda%5E/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fsWrite.ts#L104-L113

실제 전달되는 값은 /workspace/project_settings.json 이지만 운영체제의 파일쓰기 api는 이 경로가 심볼릭 링크라면 최종 대상을 따라간다.

따라서 승인 검사는 /workspace/project_settings.json 를 검사했지만 실제 파일 쓰기에서는 /home/user/.ssh/authorized_keys 가 변경된다.

 

취약 코드 전체 흐름

악성 저장소
project_settings.json
    → ~/.ssh/authorized_keys
             │
             ▼
에이전트가 fsWrite 호출
path = /workspace/project_settings.json
             │
             ▼
requiresPathAcceptance()
             │
             ├─ realpath(inputPath)
             │      └─ target 미존재로 ENOENT
             │
             ├─ realpath(parent) + basename
             │      └─ /workspace/project_settings.json
             │
             ├─ isInWorkspace() == true
             │
             └─ requiresAcceptance: false
             │
             ▼
FsWrite.handleCreate()
             │
             ▼
workspace.fs.writeFile(
    "/workspace/project_settings.json",
    attackerKey
)
             │
             ▼
OS가 symlink를 따라감
             │
             ▼
~/.ssh/authorized_keys 수정

근본적인 원인은 realpath()가 실패했을 때 해당 경로가 심볼릭 링크인지 확인하지 않는 것이 문제이다.

 

패치

https://github.com/aws/language-servers/commit/b15d04e05d7248864c72f4209c4a19baee9b7dda

 

https://github.com/aws/language-servers/blob/b15d04e05d7248864c72f4209c4a19baee9b7dda/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts#L23-L63

패치에서 이전과 중요한 차이는 realpath()가 실패했다고 곧바로 표면 경로로 들어가지 않는다는 점이다.

 

        if (stats.isSymbolicLink()) {

먼저 심볼릭 링크인지 확인하고 맞다면

            const linkTarget = await fs.promises.readlink(current)

링크 안에 저장된 대상 경로를 직접 읽는다.

            current = path.resolve(path.dirname(current), linkTarget)

이후 실제 목적지를 계속 추적한다.

따라서 대상 파일이 아직 존재하지 않는 dangling symlink도 추적이 가능하다.

 

Reference

https://github.com/aws/language-servers/security/advisories/GHSA-6v3r-4p5c-mrp5

https://github.com/cursor/cursor/security/advisories/GHSA-3v8f-48vw-3mjx

https://www.wiz.io/blog/ghostapproval-a-trust-boundary-gap-in-ai-coding-assistants

https://github.com/aws/language-servers/pull/2787