본문으로 건너뛰기

"모니터링" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

모든 태그 보기

CNPG exporter 취약점 점검

· 약 14분

CloudNativePG metrics exporter는 1.29.1과 1.28.3 이전까지 postgres superuser로 접속한 뒤 SET ROLE pg_monitor로 권한을 낮춰 쿼리를 실행했어요. SET ROLE은 current_user만 바꾸고 session_user는 그대로 두기 때문에, scrape 세션 안에서 평가되는 SQL 식이 RESET ROLE로 superuser를 되찾을 수 있었습니다. 이 결함이 CVE-2026-44477(GHSA-423p-g724-fr39)이에요. 이 글은 방어와 점검 관점에서 원인, 수정 내용, 업그레이드 순서, 점검 쿼리를 정리하고 공격 절차는 다루지 않습니다. PostgreSQL 쪽 동작은 순수 PostgreSQL 컨테이너에서 하나씩 측정했고, exploit 체인은 재현하지 않았습니다.

요약​

항목내용
식별자CVE-2026-44477, GHSA-423p-g724-fr39
공개2026-05-08 (저장소 advisory와 패치 릴리스 같은 날)
심각도Critical, CVSS 3.1 9.9 (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)
CWECWE-250, CWE-271, CWE-426
영향 버전1.28.3 미만, 1.29.0
해결 버전1.28.3, 1.29.1
필요 권한scrape 대상 database 안에서 search_path에 걸리는 객체를 만들 수 있는 계정
결과PostgreSQL superuser 획득, primary Pod 안에서 postgres OS 사용자로 명령 실행
제보Mehmet Ince

영향 범위와 해결 버전은 GitHub advisory의 패키지 범위(< 1.28.3, >= 1.29.0, < 1.29.1)와 1.29.1 릴리스 노트, 1.28.3 릴리스 노트에서 확인했습니다. 1.27 이하 라인에는 별도 패치 릴리스가 없습니다. 1.27.x는 2026-03-09에 이미 지원이 끝났습니다.

경고

2026-06-29에 같은 계열의 후속 취약점 CVE-2026-55769(GHSA-x8c2-3p4r-v9r6)가 공개됐고 1.28.4, 1.29.2, 1.30.0에서 수정됐습니다. 1.29.1이나 1.28.3에서 멈추면 이 후속 건이 남습니다. 업그레이드 목표는 아래 "업그레이드" 절을 참고하십시오.

원인: SET ROLE 뒤에 남는 superuser 신원​

수정 전 exporter는 Pod 로컬 Unix socket으로 postgres에 접속했고, 쿼리마다 read-only 트랜잭션을 열어 다음을 실행했습니다. 수정 commit의 createMonitoringTx에서 지워진 줄입니다.

// Set the pg_monitor role
_, err = tx.Exec("SET ROLE TO pg_monitor")

advisory는 이 구조를 연쇄의 출발점으로 봅니다. 요지는 세 문장입니다.

  • SET ROLE은 current_user만 바꾸고 session_user는 postgres로 남습니다.
  • scrape 세션에서 평가되는 SQL 식은 무엇이든 RESET ROLE로 superuser를 되찾고, COPY ... TO PROGRAM으로 Pod 안에서 프로세스를 띄울 수 있습니다.
  • READ ONLY 트랜잭션은 데이터베이스 상태에 대한 쓰기를 막을 뿐, 외부 프로세스 실행은 막지 않습니다.

아래 세 절은 이 문장들을 PostgreSQL 18.4 컨테이너에서 하나씩 확인한 결과입니다. 공격 객체와 권한 회복을 한 번에 엮는 코드는 쓰지 않았습니다.

SET ROLE과 session_user​

superuser로 접속해 read-only 트랜잭션 안에서 SET ROLE pg_monitor를 실행한 결과입니다.

BEGIN READ ONLY;
SET ROLE pg_monitor;
SELECT session_user, current_user,
(SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = current_user) AS cur_super,
(SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = session_user) AS sess_super;
SELECT count(*) FROM pg_catalog.pg_authid;
session_user | current_user | cur_super | sess_super
--------------+--------------+-----------+------------
postgres | pg_monitor | f | t

ERROR: permission denied for table pg_authid

권한 검사는 current_user 기준이라 pg_authid 조회가 막힙니다. 여기까지만 보면 권한이 잘 낮아진 것처럼 보입니다. 같은 트랜잭션에서 RESET ROLE을 실행하면 결과가 달라집니다.

BEGIN READ ONLY;
SET ROLE pg_monitor;
RESET ROLE;
SELECT session_user, current_user;
SELECT count(*) AS authid_rows FROM pg_catalog.pg_authid;
session_user | current_user
--------------+--------------
postgres | postgres

authid_rows
-------------
17

RESET ROLE은 current_user를 session_user로 되돌리는 명령이고, 이 명령에는 별도 권한이 필요 없습니다. session_user가 superuser인 한 SET ROLE은 격리 수단이 되지 못합니다.

SET SESSION AUTHORIZATION도 같은 결과​

advisory는 SQL 수준에서 SET SESSION AUTHORIZATION pg_monitor로 한 단계 더 내려도 부족하다고 적었습니다. SET SESSION AUTHORIZATION의 권한 검사가 현재 session_user가 아니라 처음 인증된 사용자가 superuser인지를 보기 때문입니다. 측정 결과도 같았습니다.

SET SESSION AUTHORIZATION cnpg_metrics_exporter;
SELECT session_user, current_user;
RESET SESSION AUTHORIZATION;
SELECT session_user, current_user;
SET SESSION AUTHORIZATION cnpg_metrics_exporter;
SET SESSION AUTHORIZATION postgres;
SELECT session_user, current_user;
session_user | current_user
-----------------------+-----------------------
cnpg_metrics_exporter | cnpg_metrics_exporter

session_user | current_user
--------------+--------------
postgres | postgres

session_user | current_user
--------------+--------------
postgres | postgres

session_user를 비 superuser로 바꾼 뒤에도 RESET SESSION AUTHORIZATION이나 SET SESSION AUTHORIZATION postgres로 다시 superuser가 됩니다. 인증 단계의 사용자 자체를 바꿔야 한다는 advisory의 결론이 여기서 나옵니다.

READ ONLY가 막는 것과 막지 못하는 것​

superuser 세션에서 BEGIN READ ONLY 뒤에 실행한 결과입니다.

문장결과
CREATE TABLE t2(id int)cannot execute CREATE TABLE in a read-only transaction
INSERT INTO t VALUES (1)cannot execute INSERT in a read-only transaction
SELECT nextval('s')cannot execute nextval() in a read-only transaction
SELECT pg_read_file('postgresql.conf')성공 (32,557바이트)
COPY (SELECT ...) TO '/tmp/ro_copy.txt'성공, 파일 생성 확인
COPY (SELECT 1) TO PROGRAM 'true'성공
SELECT set_config('work_mem', '8MB', true)성공

transaction_read_only는 데이터베이스 안의 테이블, 시퀀스, 카탈로그 변경을 막습니다. 서버 파일 읽기와 쓰기, 외부 프로그램 실행은 트랜잭션이 기록하는 데이터 변경이 아니어서 검사 대상에서 빠집니다. 그래서 이 조합을 막는 것은 트랜잭션 모드가 아니라 role의 권한입니다.

영향 조건​

advisory는 영향 대상을 두 갈래로 정리했습니다. 공격 방법은 이 글에서 다루지 않고 대상 여부를 판단하는 데 필요한 조건만 옮깁니다.

  • 기본 모니터링을 켠 모든 지원 릴리스. 기본 제공 default-monitoring.yaml의 pg_extensions 메트릭이 스키마를 붙이지 않은 current_database()를 호출했고 target_databases: '*'로 모든 사용자 database에서 실행됐기 때문입니다. bootstrap.initdb가 만드는 기본 app role처럼 database를 소유한 비 superuser 계정이 있으면 조건을 채웁니다.
  • 스키마를 붙이지 않은 함수나 relation을 참조하는 custom metric 쿼리를 쓰는 배포. 고객이 metric 쿼리 본문을 넣거나 바꿀 수 있는 멀티테넌트 플랫폼을 advisory는 가장 위험한 경우로 봤습니다.

advisory는 스키마 누락이 가장 직접적인 형태일 뿐이라고 덧붙였습니다. scrape 세션에서 평가되는 식은 모두 superuser 권한으로 실행됐으므로, 사용자 정의 함수, operator, cast, 사용자 소유 테이블이나 view, 읽는 객체에 걸린 index 식과 RLS policy도 같은 범주에 들어갑니다. 쿼리에 pg_catalog.를 붙이는 것만으로는 원인이 없어지지 않는다는 뜻입니다.

PostgreSQL 14 이하는 조건이 더 넓다고 advisory가 따로 적었습니다. PostgreSQL 15부터 public 스키마의 CREATE 권한이 PUBLIC에서 빠졌기 때문입니다. 두 버전에서 아무 권한 없는 login role로 확인한 결과입니다.

버전has_schema_privilege(..., 'public', 'CREATE')public ACLpublic 소유자
14.19t{postgres=UC/postgres,=UC/postgres}postgres
18.4f{pg_database_owner=UC/pg_database_owner,=U/pg_database_owner}pg_database_owner

CloudNativePG 1.29와 1.30은 PostgreSQL 14부터 18까지 지원합니다. 14를 쓰는 클러스터라면 database 소유자가 아닌 계정도 대상에 넣어 판단해야 합니다.

수정 내용​

advisory 본문은 "세 개의 패치"라고 적었지만 2026-09-28에 조회한 Patches 절에는 두 항목만 있습니다. 두 패치 모두 1.29.1과 1.28.3에 들어갔습니다.

Patch 1: 기본 쿼리의 스키마 명시​

PR #10576은 default-monitoring.yaml과 문서 예제의 카탈로그 참조에 pg_catalog.를 붙였습니다. pg_extensions 쿼리의 변경은 이렇습니다.

- current_database() as datname,
+ pg_catalog.current_database() as datname,
...
- WHEN default_version = installed_version THEN 0
+ WHEN default_version OPERATOR(pg_catalog.=) installed_version THEN 0

함수와 함께 = 같은 operator도 OPERATOR(pg_catalog.=)로 바꿨습니다. v1.29.0과 v1.29.1의 파일을 받아 스키마 없는 호출을 세어 보면 count( 3곳, now( 1곳, current_database( 1곳이 사라지고 OPERATOR( 13곳이 생깁니다. 파일 길이는 둘 다 491줄입니다. target_databases에 '*'를 쓰는 쿼리는 두 버전 모두 pg_extensions 하나입니다.

이 패치는 오퍼레이터가 배포하는 설정만 고칩니다. default-monitoring.yaml을 복사해 만든 custom ConfigMap은 사용자가 직접 고쳐야 합니다.

Patch 2: 전용 cnpg_metrics_exporter role​

commit 04e8c19가 근본 원인을 없앤 변경입니다. 18개 파일을 바꿨고, 요지는 인증 단계의 사용자 교체입니다.

  • cnpg_metrics_exporter role을 LOGIN PASSWORD NULL로 만들고 pg_monitor만 부여
  • NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS INHERIT LOGIN 속성과 pg_monitor 멤버십, 비밀번호 없음을 reconcile마다 primary에서 다시 맞춤
  • exporter 전용 connection pool이 user=cnpg_metrics_exporter application_name=cnpg_metrics_exporter로 Unix socket에 접속
  • pg_hba.conf에 local all cnpg_metrics_exporter peer map=cnpg_metrics_exporter, pg_ident.conf에 매핑 한 줄 추가
  • SET ROLE TO pg_monitor 제거, 대신 트랜잭션마다 search_path 앞에 pg_catalog를 붙임
  • target_databases: '*' 확장 시 has_database_privilege(datname, 'CONNECT')가 false인 database는 Debug 로그만 남기고 제외
  • bootstrap.initdb.import의 role 가져오기 대상에서 제외

수정 후 구조를 같은 PostgreSQL 18.4 컨테이너에서 재현했습니다. role을 commit과 같은 문장으로 만들고, 전용 계정으로 접속해 앞 절의 문장을 다시 실행한 결과입니다.

session_user | current_user
-----------------------+-----------------------
cnpg_metrics_exporter | cnpg_metrics_exporter

RESET -- 결과는 그대로 cnpg_metrics_exporter
ERROR: permission denied to set role "postgres"
ERROR: permission denied to set session authorization "postgres"
ERROR: permission denied for function pg_read_file
ERROR: permission denied to COPY to or from an external program
DETAIL: Only roles with privileges of the "pg_execute_server_program" role may COPY to or from an external program.
ERROR: permission denied to COPY to a file
ERROR: permission denied for table pg_authid

pg_stat_activity와 pg_settings는 그대로 조회됩니다. session_user가 처음부터 비 superuser이므로 RESET ROLE이 돌아갈 곳도 비 superuser입니다.

peer 매핑도 확인했습니다. commit의 두 줄을 컨테이너 설정에 넣고({{.Username}} 자리는 컨테이너 OS 사용자 postgres) reload한 뒤 pg_hba_file_rules, pg_ident_file_mappings로 읽힌 규칙을 보고 접속해 봤습니다.

접속 시도결과
OS 사용자 postgres, socket, -U cnpg_metrics_exporter성공, session_user는 cnpg_metrics_exporter
OS 사용자 root, socket, -U cnpg_metrics_exporterPeer authentication failed
TCP 127.0.0.1, 비밀번호 입력password authentication failed

비밀번호가 NULL이라 TCP로는 접속할 수 없습니다. socket에서는 Pod 안의 postgres OS 사용자만 이 role로 접속합니다.

노트

수정 후 exporter 권한은 pg_monitor 범위로 줄었습니다. 사용자 소유 테이블이나 pg_authid, pg_subscription 같은 superuser 전용 카탈로그를 읽던 custom 쿼리는 실패합니다. 이런 쿼리를 계속 쓰려면 GRANT USAGE ON SCHEMA, GRANT SELECT를 cnpg_metrics_exporter에 따로 부여해야 합니다. PUBLIC의 CONNECT를 회수한 database도 GRANT CONNECT가 필요합니다. 공식 문서는 이 role에 pg_monitor와 필요한 테이블 권한 외의 멤버십을 주지 말라고 경고합니다.

후속 취약점 CVE-2026-55769​

1.30.0 릴리스 날인 2026-06-29에 GHSA-x8c2-3p4r-v9r6이 공개됐습니다. 이번에는 exporter 대신 instance manager가 superuser로 여는 connection에 search_path가 고정돼 있지 않았던 문제입니다. 역시 CWE-426이고 critical입니다. 수정은 PR #10774이며, 오퍼레이터가 여는 모든 pooled connection의 startup message에 search_path = pg_catalog, public, pg_temp를 넣었습니다.

라인CVE-2026-44477 수정CVE-2026-55769 수정
1.281.28.31.28.4
1.291.29.11.29.2
1.301.30.0부터 포함1.30.0부터 포함

advisory는 오퍼레이터 connection에 고정 search_path를 주거나 신뢰할 수 없는 사용자에게 database 소유권을 주지 않은 클러스터는 해당되지 않는다고 적었습니다. database 소유자 계정이 이번 두 건에서 공통 조건이라는 점은 운영 관점에서 기억해 둘 만합니다.

업그레이드​

지원 정책 문서 기준으로 1.28.x는 2026-06-30에 지원이 끝났고, 1.29.x는 2026-09-29가 EOL입니다. 2026-09-23에 나온 1.30.1과 1.29.3이 현재 최신입니다. 두 CVE를 함께 정리하려면 1.30.1로 올리는 것이 가장 단순합니다. 1.29에 남는다면 최소 1.29.2가 필요하지만 EOL이 바로 다음 날입니다.

공식 문서와 릴리스 노트에서 확인한 순서와 주의 사항입니다. CloudNativePG 업그레이드 자체는 이 글에서 실행해 보지 않았습니다.

  1. 현재 오퍼레이터 버전과 대상 클러스터의 PostgreSQL major 버전 확인
  2. custom monitoring ConfigMap, Secret에서 사용자 테이블이나 superuser 전용 카탈로그를 읽는 쿼리 목록 작성
  3. 그 쿼리에 필요한 GRANT를 cnpg_metrics_exporter 기준으로 준비 (role은 업그레이드 후 생김)
  4. replica cluster가 있으면 source primary cluster를 먼저 업그레이드. role은 source primary에서 만들어져 streaming replication으로 내려가므로, replica cluster를 먼저 올리면 role이 없는 상태로 scrape합니다
  5. 업그레이드 후 instance manager 로그에서 exporter의 권한 오류, role 누락 오류 확인
  6. role이 없으면 공식 문서의 수동 생성 문장을 writable primary에서 superuser로 실행
CREATE ROLE cnpg_metrics_exporter WITH LOGIN NOSUPERUSER NOCREATEDB
NOCREATEROLE NOREPLICATION NOBYPASSRLS INHERIT;
GRANT pg_monitor TO cnpg_metrics_exporter;

바로 올릴 수 없을 때 advisory가 제시한 임시 조치는 네 가지입니다. custom 쿼리의 모든 카탈로그 참조에 pg_catalog.를 붙이기, 신뢰하는 role만 database를 소유하게 하기, target_databases: '*' 범위 줄이기, 신뢰할 수 없는 사용자에게 metric 쿼리 본문을 노출하지 않기입니다. advisory는 첫 번째가 부분 완화일 뿐이라고 명시합니다.

점검 항목​

아래 SQL은 PostgreSQL 18.4 컨테이너에서 실행해 결과 형태를 확인했습니다. CloudNativePG Pod 안에서는 실행해 보지 않았습니다. primary에 superuser로 접속해 실행하는 것을 전제로 합니다. superuser로 사용자 database에 접속할 때는 PGOPTIONS="-c search_path=pg_catalog"를 붙여 세션 search_path를 고정하십시오.

exporter가 어떤 계정으로 접속해 있는지 확인합니다. 수정 전 버전이면 usename이 postgres, rolsuper가 t로 나옵니다.

SELECT a.datname, a.usename, r.rolsuper, a.application_name
FROM pg_catalog.pg_stat_activity a
JOIN pg_catalog.pg_roles r ON r.oid = a.usesysid
WHERE a.application_name = 'cnpg_metrics_exporter';
datname | usename | rolsuper | application_name
----------+-----------------------+----------+-----------------------
app | cnpg_metrics_exporter | f | cnpg_metrics_exporter
postgres | postgres | t | cnpg_metrics_exporter

위 출력은 수정 전과 수정 후 연결을 컨테이너에 동시에 띄워 만든 예시입니다. 실제 클러스터에서는 한 종류만 나와야 합니다.

role 속성과 멤버십이 commit이 강제하는 상태와 같은지 봅니다. member_of에 pg_monitor 외의 role이 있으면 누가 왜 줬는지 확인해야 합니다.

SELECT r.rolname, r.rolsuper, r.rolinherit, r.rolcanlogin,
a.rolpassword IS NULL AS no_password,
ARRAY(SELECT b.rolname FROM pg_catalog.pg_auth_members m
JOIN pg_catalog.pg_roles b ON b.oid = m.roleid
WHERE m.member = r.oid) AS member_of
FROM pg_catalog.pg_roles r
JOIN pg_catalog.pg_authid a ON a.oid = r.oid
WHERE r.rolname = 'cnpg_metrics_exporter';
rolname | rolsuper | rolinherit | rolcanlogin | no_password | member_of
-----------------------+----------+------------+-------------+-------------+--------------
cnpg_metrics_exporter | f | t | t | t | {pg_monitor}

database 소유자가 누구인지 봅니다. 두 CVE 모두 database 소유자 계정이 조건에 들어가므로, 애플리케이션 계정이 소유자인지 확인해 둡니다.

SELECT d.datname, pg_catalog.pg_get_userbyid(d.datdba) AS owner, r.rolsuper AS owner_is_super
FROM pg_catalog.pg_database d
JOIN pg_catalog.pg_roles r ON r.oid = d.datdba
WHERE NOT d.datistemplate
ORDER BY 1;

database나 role 수준에서 search_path를 바꿔 둔 설정이 있는지 봅니다. 의도하지 않은 항목이 있으면 설정한 사람과 이유를 확인합니다.

SELECT d.datname, r.rolname, s.setconfig
FROM pg_catalog.pg_db_role_setting s
LEFT JOIN pg_catalog.pg_database d ON d.oid = s.setdatabase
LEFT JOIN pg_catalog.pg_roles r ON r.oid = s.setrole
WHERE pg_catalog.array_to_string(s.setconfig, ',') LIKE '%search_path%';

pg_catalog 밖에 pg_catalog 함수와 같은 이름의 함수, 또는 사용자 operator가 있는지 database마다 확인합니다. 확장 소속 객체는 pg_depend의 deptype = 'e'로 제외했습니다. 이 조건이 없으면 citext 하나만 설치해도 동명 함수 25개가 나오고, 조건을 넣으면 확장이 아닌 객체만 남습니다.

SELECT n.nspname AS schema, p.proname AS name, 'function' AS kind,
pg_catalog.pg_get_userbyid(p.proowner) AS owner
FROM pg_catalog.pg_proc p
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND EXISTS (SELECT 1 FROM pg_catalog.pg_proc c
WHERE c.pronamespace = 'pg_catalog'::pg_catalog.regnamespace
AND c.proname OPERATOR(pg_catalog.=) p.proname)
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d
WHERE d.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass
AND d.objid = p.oid AND d.deptype = 'e')
UNION ALL
SELECT n.nspname, o.oprname, 'operator', pg_catalog.pg_get_userbyid(o.oprowner)
FROM pg_catalog.pg_operator o
JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d
WHERE d.classid = 'pg_catalog.pg_operator'::pg_catalog.regclass
AND d.objid = o.oid AND d.deptype = 'e')
ORDER BY 1, 2;

결과가 있다고 해서 공격 흔적이라는 뜻은 아닙니다. 애플리케이션이 일부러 만든 함수일 수도 있으므로 소유자와 생성 시점을 확인하는 출발점으로 쓰십시오.

custom 쿼리 본문은 Kubernetes 쪽에서 확인합니다. 아래 명령은 이 글에서 실행하지 않았고, 클러스터 spec의 monitoring.customQueriesConfigMap 경로를 따른 것입니다.

kubectl get cluster -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{.spec.monitoring.customQueriesConfigMap}{"\n"}{end}'
kubectl get configmap <name> -n <ns> -o yaml | grep -nE "target_databases|current_database\(|now\("

측정 환경과 한계​

  • macOS 호스트의 Colima VM (Docker 28.3.3, 2 CPU, 약 12GB, Linux 6.8, arm64), 다른 작업과 공유
  • postgres:18 (PostgreSQL 18.4), postgres:14 (PostgreSQL 14.19) 컨테이너
  • CloudNativePG 오퍼레이터와 Kubernetes 클러스터는 띄우지 않았습니다. CNPG 쪽 사실은 advisory, commit diff, 릴리스 노트, 지원 정책 문서, default-monitoring.yaml 원본으로만 확인했습니다
  • exploit 체인은 재현하지 않았고, PostgreSQL 동작을 하나씩 따로 확인했습니다

관련 글​

  • pg_maintain 권한 위임 정리: 유지보수 명령 중 search_path를 고정하는 PostgreSQL 17의 방어 장치를 다룹니다. 권한을 위임받은 세션이 무엇을 실행하게 되는지를 본다는 점에서 이 글과 같은 질문입니다
  • CloudNativePG 10.1 모니터링: exporter 구조, 9187 포트, custom 쿼리 ConfigMap 작성법

참고​

SET ROLE로 권한을 낮춘 연결은 처음 인증한 신원을 계속 기억해요. 모니터링처럼 주기적으로 도는 연결일수록 처음부터 낮은 권한으로 인증하는 편이 맞다는 것이 이번 수정에서 제가 얻은 기준이에요. 아직 1.29.1 이전이라면 1.30.1로 올리고 위 점검 쿼리를 한 번 실행해 보시길 권해요.

PMM ClickHouse 권한 취약점

· 약 6분

DB를 지키려고 붙인 모니터링 도구가 DB로 들어가는 문이 되면 곤란하겠죠. 2026년 8월 19일 Percona가 자사 모니터링 제품에서 그런 경로를 공개했습니다.

Percona 보안 공지의 대상은 Percona Monitoring and Management(PMM) 3.9.0 이하입니다. CVSS는 8.7, CVE 번호는 공지 시점에 아직 배정되지 않았고 배정되면 공지를 갱신한다고 적혀 있습니다.

문제의 모양​

PMM은 Grafana를 화면으로 쓰고, 쿼리 분석 데이터를 ClickHouse에 담습니다. 문제는 그 둘을 잇는 데이터소스 설정에 있었습니다.

두 가지가 겹칩니다. 첫째, Grafana는 로그인한 사용자가 raw data source API에 접근하는 것을 허용합니다. Viewer 역할도 포함입니다. 대시보드만 볼 수 있는 계정이 데이터소스에 직접 쿼리를 보낼 수 있다는 뜻입니다. 둘째, ClickHouse 데이터소스가 연결할 때 쓰는 기본 신원이 전역 DDL, DML, SOURCES 권한을 가지고 있었습니다.

Grafana에서 anonymous access를 켜 두면 로그인하지 않은 사용자까지 이 경로에 닿습니다. 기본값은 꺼져 있습니다. 이 조건은 명확히 해 두는 게 좋습니다. 뒤에 나오는 전체 공격 체인은 anonymous access가 켜져 있고 AWS EC2에 올라간 배포에서 성립합니다.

SOURCES 권한이 실제로 무엇을 여는가​

공지가 url() 함수를 언급하는데, ClickHouse를 자주 다루지 않으면 이게 왜 위험한지 감이 안 옵니다. 직접 확인해 봤습니다. 기본 설정으로 ClickHouse 컨테이너를 띄웠습니다.

$ docker run -d --name chtest -e CLICKHOUSE_PASSWORD=pw \
clickhouse/clickhouse-server:latest
$ docker exec chtest clickhouse-client --password pw -q "select version()"
26.7.5.10

default 사용자가 가진 권한을 그대로 뽑아 봤습니다.

show grants for default;
GRANT SOURCES ON *.* TO default
GRANT TABLE ENGINE ON * TO default
GRANT CHECK, SHOW, SELECT, INSERT, ALTER, CREATE, DROP, UNDROP TABLE,
TRUNCATE, OPTIMIZE, BACKUP, KILL QUERY, KILL TRANSACTION,
MOVE PARTITION BETWEEN SHARDS, SYSTEM, dictGet,
displaySecretsInShowAndSelect, INTROSPECTION, CLUSTER,
FILE, URL, REMOTE, MONGO, REDIS, MYSQL, POSTGRES, SQLITE,
ODBC, JDBC, HDFS, S3, HIVE, AZURE, KAFKA, NATS, RABBITMQ,
YTSAURUS, ARROW FLIGHT, SOURCES ON *.* TO default

목록을 보면 성격이 드러납니다. FILE, URL, REMOTE, S3, MONGO, MYSQL, POSTGRES, HDFS가 전부 들어 있습니다. SOURCES는 이들을 묶은 상위 권한입니다.

이게 뜻하는 바는 ClickHouse의 쿼리가 데이터베이스 안에서 끝나지 않는다는 것입니다. url() 테이블 함수는 SELECT 문 안에서 HTTP 요청을 보냅니다. s3()는 S3 버킷을 읽습니다. file()은 서버 로컬 파일을 테이블처럼 읽습니다. SELECT 권한만 있는 계정이 아니라, HTTP 클라이언트를 겸하는 계정이 되는 셈입니다.

file()에는 별도 울타리가 하나 더 있습니다. 임의 경로는 막습니다.

$ clickhouse-client -q "select count() from file('/etc/hostname','LineAsString')"
Code: 291. DB::Exception: File `/etc/hostname` is not inside
`/var/lib/clickhouse/user_files`. (DATABASE_ACCESS_DENIED)

user_files 디렉터리 밖은 거부됩니다. 다만 그 안이라면 그대로 읽힙니다.

$ docker exec chtest sh -c 'echo secret-token-abc > /var/lib/clickhouse/user_files/t.txt'
$ clickhouse-client -q "select * from file('t.txt','LineAsString')"
secret-token-abc

네트워크 쪽은 울타리가 없습니다. 권한이 실제로 통과하는지 확인하려고 닫힌 포트를 겨냥해 봤습니다.

$ clickhouse-client -q "select * from url('http://127.0.0.1:1/x','LineAsString')"
Code: 1000. DB::Exception: Connection refused. (POCO_EXCEPTION)

돌아온 것이 권한 거부가 아니라 연결 거부입니다. 권한 검사를 통과해 실제로 소켓을 열려고 시도했다는 뜻입니다. 겨냥한 주소가 열려 있었다면 요청이 나갔을 것입니다. SELECT 문 하나가 DB 서버에서 출발하는 HTTP 요청이 되는 지점입니다.

공격 체인​

공지가 설명하는 흐름은 네 단계입니다.

url() 함수로 AWS IMDSv1에 요청을 보냅니다. 인스턴스 메타데이터 서비스의 v1은 토큰 없이 GET 하나로 응답합니다. 여기서 EC2 인스턴스에 붙은 role의 임시 자격증명을 얻습니다.

그 자격증명으로 S3에 접근해 Terraform state 파일을 읽습니다. state 파일에는 인프라 구성이 그대로 들어 있고, 관리자 비밀번호 같은 값이 평문으로 남아 있는 경우가 흔합니다.

마지막으로 그 값으로 PMM 관리자로 인증합니다. 모니터링 시스템의 관리자가 되면 감시 대상 DB의 접속 정보를 그 안에서 찾을 수 있습니다.

단계마다 그 자체로는 알려진 문제입니다. IMDSv1이 토큰 없이 응답하는 것도, Terraform state에 비밀이 남는 것도 오래된 이야기입니다. 이 공지가 보여 주는 건 그 알려진 약점들이 SELECT 권한 하나로 연결된다는 점입니다.

Grafana 데이터소스에서 감시 대상 DB 정보까지 이어지는 공격 체인

수정과 완화​

수정 버전은 PMM 3.9.1이고 2026년 8월 19일 나왔습니다. 근본 수정은 이쪽입니다.

바로 올릴 수 없는 환경을 위해 공지가 완화 스크립트를 함께 제공합니다. 방향은 단순합니다. 데이터소스가 쓰는 계정을 읽기 전용으로 새로 만들고, SOURCES 계열을 주지 않는 것입니다.

CREATE USER IF NOT EXISTS grafana_ro
IDENTIFIED WITH plaintext_password BY '<password>'
SETTINGS readonly = 1;
GRANT SELECT ON pmm.* TO grafana_ro;

readonly = 1을 걸고 pmm 데이터베이스에 SELECT만 줍니다. SOURCES를 주지 않으면 url(), s3(), mongodb(), remote(), file() 호출이 막힙니다. 대시보드가 필요한 것은 SELECT뿐이니 기능은 그대로 돕니다.

참고로 위 CREATE USER를 기본 default 계정으로 실행하면 거부될 수 있습니다. 제 컨테이너에서는 이렇게 나왔습니다.

Code: 497. DB::Exception: default: Not enough privileges.
To execute this query, it's necessary to have the grant
CREATE USER ON grafana_ro. (ACCESS_DENIED)

SOURCES는 넉넉히 주면서 사용자 관리 권한은 안 주는 기본 구성입니다. 이 스크립트를 돌리려면 ACCESS MANAGEMENT 권한이 있는 계정으로 접속해야 합니다.

관측 스택 계정 권한을 점검하는 자리​

공지가 다루지 않는 부분을 하나 짚고 싶습니다. 이번 문제의 뼈대는 PMM에 국한되지 않습니다. "대시보드가 DB에 붙을 때 어떤 권한으로 붙는가"라는 질문입니다. 이 질문은 어느 관측 스택에나 유효합니다.

점검할 지점을 정리하면 이렇습니다.

확인할 것왜
데이터소스 계정의 권한 목록SELECT 외에 무엇이 붙어 있는지. ClickHouse면 SOURCES 유무
Grafana anonymous access 설정켜져 있으면 비인증 사용자가 데이터소스 API에 닿는다
Viewer 역할의 실제 범위대시보드 열람과 raw query 실행이 같은 등급인지
DB 서버에서 나가는 네트워크DB가 외부로 HTTP를 보낼 수 있는지
IMDS 버전v1이 열려 있으면 SSRF 한 방이 자격증명이 된다
Terraform state 저장소평문 비밀이 남아 있는지, 접근 주체가 누구인지

DB 계정 권한부터 보는 게 순서지만, 네트워크 쪽에서도 한 겹 막을 수 있습니다. DB 서버가 외부로 HTTP를 보낼 이유가 대개 없습니다. IMDSv2를 강제하는 것도 이 체인 두 번째 단계를 끊습니다.

남는 생각​

Copy Fail이나 NGINX Rift 같은 사례는 코드 한 줄의 결함이었습니다. 이번 건은 성격이 다릅니다. ClickHouse가 url()을 제공하는 것도, Grafana가 데이터소스 API를 여는 것도 각각은 의도된 기능입니다. 두 기능이 만나는 자리에서 권한 설정이 넉넉했던 것이 문제였습니다.

그래서 패치만으로 끝나지 않습니다. 3.9.1로 올린 뒤에도 데이터소스 계정에 무엇이 붙어 있는지 한 번 확인해 볼 만합니다. 저는 이 공지를 읽고 나서 ClickHouse의 SOURCES 권한이 그렇게 많은 것을 묶고 있는 줄 몰랐다는 걸 알았어요.

참고​

pg_stat_statements 해설

· 약 9분

pg_stat_statements는 컬럼이 스무 개가 넘지만, DBA가 매일 보는 건 사실 대여섯 개뿐이에요. 나머지는 그 대여섯 개를 해석할 때 보조로 쓰이며, 이 글에서는 핵심 컬럼을 읽는 법과 이 확장의 내부 동작 때문에 가끔 데이터가 비어 보이는 이유를 운영 관점에서 정리해요.

같은 주에 boringSQL과 pganalyze가 이 주제를 각각 깊게 다뤘습니다. 두 글을 PostgreSQL 공식 문서로 교차 확인하면서 한국어 실무 가이드로 다시 엮었습니다.

pg_stat_statements가 수집하는 것​

pg_stat_statements는 공유 메모리에 있는 고정 크기 해시 테이블입니다. 개별 실행을 하나하나 기록하는 게 아니라, 같은 모양의 쿼리를 하나의 항목으로 묶어 누적 카운터를 갱신합니다.

여기서 "같은 모양"의 기준은 정규화(normalization)입니다. 상수를 떼어내고 $1, $2 같은 자리표시자로 바꾼 뒤, 파싱된 쿼리 트리의 해시값을 queryid로 삼습니다. 그래서 아래 두 쿼리는 하나의 항목으로 집계됩니다.

SELECT * FROM users WHERE id = 42;
SELECT * FROM users WHERE id = 99;

여기서 DBA가 미리 알아둬야 할 정규화의 성질이 몇 가지 있습니다.

  • queryid는 메이저 버전 사이에서 안정적이지 않습니다. 같은 SQL이라도 메이저 업그레이드 후, 혹은 다른 CPU 아키텍처에서 다른 값으로 해시될 수 있습니다. 여러 서버의 통계를 묶을 때 queryid를 영구 조인 키로 쓰면 안 됩니다.
  • OID 기반이라 이름 기반이 아닙니다. 테이블을 drop하고 다시 만들면 새 OID가 붙고, 그 결과 새 queryid가 생깁니다. 이전 통계는 고아로 남습니다.
  • 구조에 민감합니다. 테이블 alias가 다르거나, 컬럼 목록이 바뀌거나, LIMIT이 붙거나, 조건 순서가 달라지면 논리적으로 같은 쿼리가 여러 항목으로 쪼개집니다. ORM이 이런 변형을 대량으로 쏟아내면 항목이 수백 개로 흩어지기도 합니다.

또 하나, 중요한 동작상의 제약이 있습니다. pg_stat_statements는 ExecutorEnd 단계에서 통계를 기록합니다. 즉 정상적으로 끝난 실행만 집계됩니다. 타임아웃으로 끊기거나 중간에 abort된 쿼리는 흔적을 남기지 않습니다. 장애 시점에 문제를 일으킨 쿼리가 정작 이 뷰에 안 보이는 경우가 여기서 나옵니다.

쿼리 정규화와 공유 해시 항목 누적 흐름

DBA가 봐야 할 핵심 컬럼​

전체 컬럼은 공식 문서에 다 있습니다. 여기서는 운영 중에 실제로 손이 가는 것만 추립니다.

컬럼의미언제 보나
calls실행 횟수빈도. 한 번 느린지, 자주 느린지 구분
total_exec_time누적 실행 시간(ms)서버 부하에 가장 크게 기여하는 쿼리 찾기
mean_exec_time평균 실행 시간(ms)한 번 실행이 비싼 쿼리 찾기
min/max/stddev_exec_time최소, 최대, 표준편차(ms)편차가 큰 쿼리 식별
rows반환/영향 행 수호출당 결과 규모 추정
shared_blks_hitbuffer cache 적중 블록 수캐시 효율
shared_blks_read디스크에서 읽은 공유 블록 수실제 I/O 부담
wal_bytes생성한 WAL 양(bytes)쓰기 부하/복제 부담

느린 쿼리를 찾을 때 mean과 total 구분하기​

가장 먼저 정해야 할 건 정렬 기준입니다. mean_exec_time과 total_exec_time은 전혀 다른 질문에 답합니다.

  • total_exec_time이 큰 쿼리는 서버 전체 부하에 가장 많이 기여하는 쿼리입니다. 한 번은 1ms로 빨라도 하루에 수천만 번 실행되면 누적 시간이 1위가 됩니다. 서버가 바쁜 원인을 찾을 때는 이 값을 봅니다.
  • mean_exec_time이 큰 쿼리는 한 번 실행이 비싼 쿼리입니다. 빈도는 낮아도 실행할 때마다 사용자를 기다리게 만듭니다. 특정 화면이 느리다는 제보를 추적할 때 유용합니다.
-- 누적 부하 상위 10개
SELECT
queryid,
calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows,
query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

calls를 항상 함께 봐야 둘을 헷갈리지 않습니다. total_exec_time이 높은데 calls도 높으면 "자주 불려서 누적된" 것이고, calls가 낮은데 total이 높으면 "한 번이 무거운" 것입니다.

평균에서 사라지는 분포​

pg_stat_statements는 평균(mean)과 표준편차(stddev)만 유지하고, 개별 실행 시간은 즉시 버립니다. 그래서 분포의 모양을 알 수 없습니다.

예를 들어 99%는 1ms에 끝나지만 1%는 2초가 걸리는 쿼리가 있다면, 평균은 약 21ms 근처로 찍힙니다. 이 21ms는 어느 실행도 대표하지 못하는 숫자입니다. 정작 문제가 되는 2초짜리 1%는 나머지 99%에 희석돼 평균 뒤로 묻힙니다. 게다가 pg_stat_statements는 p99(실행의 99%가 그 안에 들어오는 시간, 상위 1%를 잘라낸 경계) 같은 percentile 값을 주지 않으니, 평균만 봐서는 이런 느린 실행을 따로 짚을 길이 없습니다. stddev_exec_time이 평균에 비해 유난히 크거나 max_exec_time이 평균과 크게 벌어져 있다면, 그 쿼리는 분포가 넓다는 신호이니 별도로 들여다볼 가치가 있습니다.

I/O 컬럼의 hit와 read 읽기​

shared_blks_hit과 shared_blks_read는 짝으로 읽습니다.

  • shared_blks_hit은 buffer cache에서 바로 찾은 블록 수입니다. 빠릅니다.
  • shared_blks_read는 buffer에 없어서 디스크(또는 OS page cache)에서 읽어 온 블록 수입니다. 느립니다.

shared_blks_read가 크다는 건 그 쿼리가 실제 I/O를 많이 일으킨다는 뜻입니다. 작업 집합이 buffer pool보다 크거나, 인덱스 없이 큰 테이블을 스캔하고 있을 가능성이 있습니다. 두 값을 합친 대비 적중률을 보면 캐시 효율을 가늠합니다.

읽기/쓰기에 걸린 실제 시간까지 보려면 shared_blk_read_time, shared_blk_write_time 컬럼을 봅니다. 단, 이 값은 track_io_timing이 켜져 있어야 채워지고, 꺼져 있으면 전부 0입니다.

temp_blks_read, temp_blks_written은 임시 파일 I/O입니다. 정렬이나 해시 조인이 work_mem을 넘겨서 디스크로 흘러넘쳤다는 신호이므로, 이 값이 큰 쿼리는 work_mem 조정이나 쿼리 재작성 후보입니다.

쓰기 부하를 보여주는 WAL 컬럼​

wal_bytes, wal_records, wal_fpi는 그 쿼리가 만든 WAL 양을 보여줍니다. 쓰기가 많은 쿼리, 즉 replication 지연이나 디스크 쓰기 부하의 원인을 찾을 때 봅니다. wal_fpi(full page image)가 유난히 크면 checkpoint 직후의 첫 쓰기에서 페이지 전체가 WAL에 기록되는 패턴일 수 있습니다.

버전에 따른 total_time 분리​

PostgreSQL 13에서 타이밍 컬럼 이름이 바뀌었습니다. 12 이하에서는 total_time, mean_time 한 묶음이었지만, 13부터 계획(planning)과 실행(execution)이 분리되어 total_plan_time/total_exec_time, mean_plan_time/mean_exec_time으로 나뉘었습니다. 12 이하를 대상으로 한 옛 쿼리는 13 이상에서 컬럼명을 바꿔줘야 동작합니다.

단, 계획 시간 컬럼(total_plan_time 등)은 pg_stat_statements.track_planning이 켜져 있어야 채워집니다. 이 설정의 기본값은 off입니다. 계획 시간이 전부 0으로 보인다면 이 설정부터 확인합니다.

PostgreSQL 17부터는 stats_since(항목 통계 수집이 시작된 시각)와 minmax_stats_since(min/max 통계 수집 시작 시각) 컬럼이 추가됐습니다. 어떤 구간을 측정할 때 그 사이에 eviction이나 리셋이 끼었는지 판단하는 데 씁니다.

해시 테이블이 가득 찰 때의 eviction과 dealloc​

pg_stat_statements가 추적하는 서로 다른 쿼리 수에는 상한이 있습니다. pg_stat_statements.max이고 기본값은 5000입니다. 이 값은 서버 시작 시에만 설정할 수 있습니다.

서로 다른 쿼리 수가 이 상한을 넘으면 eviction(축출)이 일어납니다. 공식 문서는 "가장 적게 실행된(least-executed) 항목 정보를 버려서 새 항목 자리를 만든다"고 설명합니다. 내부적으로는 각 항목에 겉으로 드러나지 않는 usage 카운터가 있고, deallocation 시점에 모든 usage 값을 일정 비율 줄인 뒤 정렬해서 하위 일부를 버립니다. 이 정렬은 항목 수에 비례하는 작업이며, exclusive lock을 잡은 채로 진행됩니다.

그래서 pg_stat_statements.max를 무작정 키우는 건 공짜가 아닙니다. max가 클수록 deallocation 시점의 정렬 비용이 커지고, 그동안 lock 경합도 길어집니다.

축출이 일어났는지는 별도 뷰인 pg_stat_statements_info로 확인합니다. 이 뷰는 단 한 행이고 컬럼 두 개를 가집니다.

컬럼의미
deallocmax를 초과해 항목이 축출된 누적 횟수
stats_reset전체 통계가 마지막으로 리셋된 시각
SELECT dealloc, stats_reset FROM pg_stat_statements_info;

dealloc이 계속 올라간다면, 테이블이 쉼 없이 가득 차고 비워지는 중입니다. 이 상태에서는 자주 실행되지 않는 쿼리의 통계가 수시로 사라지므로 데이터를 신뢰하기 어렵습니다. ORM이 구조가 조금씩 다른 쿼리를 대량으로 흘려보내 해시 테이블을 가득 채우는 경우가 대표적입니다. 이때 실행 빈도가 낮은 항목이 먼저 축출되면서, 나중에 그 쿼리를 되짚어볼 단서 자체가 남지 않습니다.

대응은 두 갈래입니다. max를 적당히 늘리거나(정렬 비용 증가를 감수하고), 애초에 쿼리 변형 수를 줄이는 것입니다. 예컨대 길이가 제각각인 IN 목록은 PostgreSQL 18 이전까지 길이마다 별도 항목을 만들었는데, = ANY($1) 배열 바인딩으로 바꾸면 버전과 무관하게 하나의 항목으로 묶입니다.

아래 도식은 새 쿼리가 들어왔을 때 항목이 잡히고 축출되기까지의 흐름입니다.

운영 관점의 리셋 전략과 함정​

단조 증가하는 누적 통계​

뷰의 카운터는 마지막 리셋 이후로 계속 쌓입니다. 그래서 "지금 어떤 쿼리가 부하를 일으키는가"를 보려면 두 시점의 스냅샷을 떠서 빼는 수밖에 없습니다. 한 번 조회한 절댓값만으로는 한 달 전 배치 작업과 방금 들어온 트래픽이 뒤섞여 보입니다.

모니터링 도구들이 일정 간격으로 뷰를 떠다가 차분을 계산하는 이유가 이것입니다. 직접 차분을 계산할 때는 그 사이에 eviction이나 리셋이 끼면 음수가 나올 수 있으니, stats_since(17 이상)나 pg_stat_statements_info의 dealloc/stats_reset을 함께 확인해야 합니다.

리셋 함수​

pg_stat_statements_reset()으로 통계를 비웁니다. 인자 없이 호출하면 전체를 리셋하고, 특정 대상만 비울 수도 있습니다.

-- 전체 리셋
SELECT pg_stat_statements_reset();

-- 특정 user / db / queryid만 리셋 (0은 "전체" 의미)
SELECT pg_stat_statements_reset(0, 0, :queryid);

minmax_only 인자를 true로 주면 min/max_exec_time, min/max_plan_time만 비웁니다. 평균과 누적은 살려두고 최소/최대만 다시 측정하고 싶을 때 씁니다. 기본값은 superuser만 실행할 수 있고, 필요하면 GRANT로 권한을 넘깁니다.

리셋 전략에는 정답이 없습니다. 흔한 선택은 두 가지입니다.

  • 리셋하지 않고 누적 + 차분. 모니터링 도구에 맡기는 방식. 장기 추세를 잃지 않습니다.
  • 정기 리셋(예: 배포/점검 전후). 특정 구간만 깨끗하게 보고 싶을 때. 단, 리셋 순간 이전 데이터는 사라지니 추세 분석은 포기합니다.

자주 밟는 함정 정리​

  • 타임아웃/abort된 쿼리는 집계되지 않습니다. 장애 원인 쿼리가 뷰에 없을 수 있습니다.
  • 평균만으로 판단하면 드물게 튀는 느린 실행을 놓칩니다. max_exec_time/stddev로 분포를 함께 의심합니다.
  • dealloc이 오르는 상태에서는 통계가 불완전합니다. 먼저 그것부터 해결합니다.
  • 계획 시간(*_plan_time)이 0이면 track_planning이 꺼진 것입니다.
  • I/O 시간(shared_blk_read_time 등)이 0이면 track_io_timing이 꺼진 것입니다.
  • queryid는 메이저 버전/아키텍처 간에 달라질 수 있어 영구 키로 부적합합니다.

정리​

pg_stat_statements는 컬럼이 많아 보여도 DBA의 시선은 결국 몇 갈래로 좁혀져요. 부하의 원인은 total_exec_time, 느린 한 방은 mean_exec_time으로 찾고 둘 다 calls로 맥락을 잡으며, I/O가 의심되면 shared_blks_read와 temp 블록을, 쓰기가 의심되면 wal_bytes를 봐요. 데이터가 비어 보이거나 음수가 나오면 pg_stat_statements_info의 dealloc과 stats_reset을 먼저 확인하면 되고, 이 정도만 손에 익히면 대부분의 쿼리 튜닝은 이 뷰 하나에서 출발할 수 있어요.


참고한 출처: