Tyojong
[Amazon Q Developer] CVE-2026-12958 본문
개요
대상 제품: 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)를 호출하게 된다.
파일을 쓰기 위해서는 사용자의 승인을 받아야한다.

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

/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로 설정된다.

위 과정 후 동일한 파일 아래 코드를 더 확인해보면
const isInWs = workspaceUtils.isInWorkspace(workspaceFolders, canonicalPath)
canonicalPath가 위 과정을 거쳐 isInWorkspace(["/workspace"], "/workspace/project_settings.json") 이 만들어진다.
때문에 isInWs는 true로 설정이된다.
isInWorkspace 함수를 살펴보고 싶다면 아래 링크 참고
if (isInWs) {
return { requiresAcceptance: false }
}
isInWs가 true이므로 return { requiresAcceptance: false }가 반환된다
requiresAcceptance: false는 "워크 스페이스 내부의 파일이므로 별도의 사용자 승인이 필요하지 않다"라는 의미이다.

승인 검사를 통과하면 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()가 호출된다.

실제 전달되는 값은 /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

패치에서 이전과 중요한 차이는 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