방치된 인스톨러 페이지에서 시작하는 RCE
공개 범위에 관한 안내. 이 글은 취약점의 발생 원리·영향 조건·취약 코드와 패치·운영자 대응을 설명하기 위한 기록이다. 최근 AI·자동화 도구를 이용한 무차별 공격 동향과, 전담 관리자 없이 운영되는 사이트가 많은 현실을 고려해, 구체적인 공격용 페이로드와 그것을 포함한 실행 명령·요청 예시, 단계별 재현 절차는 본문과 별도 첨부 모두에서 의도적으로 생략했다. 이 조정은 벤더와의 협의에 따른 것이다.
서버에 파일만 올려둔 채 설치를 끝맺지 않고 인스톨러를 외부에 노출하면, 인증 없이 설치 처리를 실행할 수 있는 상태로 남는다. 설치를 시작만 해두고 방치하는 사례는 생각보다 흔하다. 이 글은 이러한 설치 전·설치 미완료 환경에 노출된 인스톨러 페이지에 무엇이 가능한가라는 질문에서 출발한다. 미리 분명히 해 둘 것이 하나 있다. 정상 설치가 끝나 설정 파일이 존재하고 그로 인해 설치 처리가 차단되는 환경은, install/ 디렉터리가 남아 있다는 이유만으로 이 공격의 대상이 되지는 않는다.
인스톨러는 태생적으로 위험한 코드다. 로그인·권한 같은 보호 장치가 아직 세워지기 전 단계에서 동작해야 하므로 대개 인증 없이 열려 있고, 하는 일이라곤 "사용자가 입력한 값을 받아 서버 설정 파일로 굳히는 것"이다.
그런데 많은 PHP CMS는 이 설정 파일을 상수·배열을 정의하는 PHP 파일로 만든다. 그리고 설치 이후 모든 요청이 거쳐 가는 공통 진입점에서, 매 요청마다 이 파일을 불러와 함께 실행한다. 그래서 인스톨러가 입력값을 제대로 검증·인코딩하지 않으면 "설정 파일에 남긴 문자열"이 곧 "매 요청 실행되는 코드"가 되는 구조가 쉽게 성립한다.
[인증 없이 열린 입구, 입력값을 그대로 믿는 설치 로직, 코드로 평가되는 설정 파일] 이 셋이 겹치는 자리가 바로 설치 전·미완료 상태로 방치된 인스톨러다.
이 패턴이 실제로 성립하는지 확인하기 위해, 대상은 그누보드5(gnuboard5)로 잡았다. 웹사이트 구축에 널리 사용되는 오픈소스 CMS다. 다만 미리 밝혀둘 게 하나 있다. 이 글은 일반론이 아니라 직접 발굴한 실제 취약점 하나를 끝까지 따라가며 설명하는 기록이고, 그래서 여기서 보이는 세부 흐름이 다른 CMS에까지 그대로 일반화되는 이야기는 아니다. 이 취약점은 취약 버전의 설치 전·설치 미완료 상태에서 인스톨러가 외부에 노출되고, DB 연결 및 설정 파일 작성 등 설치에 필요한 조건이 충족될 때 성립한다.
결론부터 적으면 이렇다. 인스톨러는 사용자 입력을 PHP 코드 형태 그대로 설정 파일 data/dbconfig.php에 기록한다. 그리고 이 파일은 이후 모든 일반 페이지 요청마다 common.php에서 include 된다. 설치 단계에서 코드가 한 번 심어지면, 그다음 일반 페이지가 로드되는 순간 웹 서버 권한으로 코드가 실행되는 구조가 성립한다.
아래는 v5.6.26을 기준으로 코드 흐름을 따라가며 왜 이 문제가 성립하는지 분석한 기록이다. 이 문제는 이미 v5.6.27에서 수정되었으니, 아래 내용은 v5.6.26을 기준으로 읽으면 된다.
An Unfinished Installer Is an Open Door
그누보드5의 최초 설치는 install/ 아래 스크립트로 3단계를 거친다.
install/install.php — 시작/라이선스
install/install_config.php — DB 접속 정보, 테이블 접두사, 관리자 계정 입력 폼
install/install_db.php — 입력값으로 테이블을 만들고 설정 파일 data/dbconfig.php를 작성
핵심은 3단계가 만들어내는 data/dbconfig.php다. 이름만 보면 DB 설정 파일이지만, 실체는 상수와 배열을 정의하는 PHP 파일이다. 그리고 설치 이후 그누보드의 공통 진입점 common.php가 매 요청마다 이 파일을 불러온다.
// common.php:154
$dbconfig_file = G5_DATA_PATH.'/'.G5_DBCONFIG_FILE; // data/dbconfig.php
if (file_exists($dbconfig_file)) {
include_once($dbconfig_file); // common.php:156 ← 매 요청 include
// ...
}
여기서 include, include_once가 왜 중요한지 짚고 가자. PHP의 include는 지정한 파일을 그냥 "읽어오는" 게 아니라, 그 파일에 적힌 PHP 코드를 지금 이 자리에 복사해 붙여넣은 것처럼 실행한다. 설정 파일을 "불러온다"는 말이, 실제로는 "그 파일을 실행한다"는 뜻인 셈이다.
그래서 dbconfig.php가 상수 정의만 얌전히 담고 있으면 상수들이 채워질 뿐이다. 하지만 그 안에 PHP 한 줄을 끼워 넣을 수 있다면, 그 줄 역시 파일이 include 되는 순간 똑같이 실행된다. 이름만 '설정 파일'일 뿐, PHP 입장에서는 매 요청 실행되는 코드 파일인 셈이다. 그러니 분석의 목표도 분명해진다.
"이 파일에 코드를 넣을 입력 경로가 있는가." 그 입력 경로가 바로 3단계의 install_db.php다. 다만 이 입구가 아무 때나 열려 있는 것은 아니다. install_db.php는 POST를 처리하기 전에 install.inc.php를 불러오는데, 여기엔 설정 파일이 이미 있으면 설치를 막는 가드가 있다.
// install/install.inc.php (install_db.php가 POST 처리 전에 include)
$dbconfig_file = $data_path.'/'.G5_DBCONFIG_FILE; // data/dbconfig.php
if (file_exists($dbconfig_file)) {
// "프로그램이 이미 설치되어 있습니다." 출력 후
exit; // ← 여기서 중단
}
즉 설치가 끝나 data/dbconfig.php가 존재하는 정상 운영 사이트에서는 이 입구가 닫혀 있다. 공격이 성립하는 구간은 dbconfig.php가 아직 없는 설치 전·설치 미완료 방치 상태뿐이고, 이 글의 분석과 영향도 모두 그 전제 위에 있다.
그리고 그 전제가 성립하는 동안에는 문턱이 사실상 없다. install_db.php가 CSRF 토큰이나 설치 세션 검증 없이 $_POST를 그대로 읽는 탓에, 노출된 인스톨러에는 인증 없는 공격자가 곧바로 요청을 던질 수 있다. "방치된 입구"라는 말 그대로다.
Two Prefixes, One Check
install_db.php는 POST 파라미터를 받아온다. 테이블 접두사 table_prefix에는 서버 측 화이트리스트 검증이 붙어 있다.
// install/install_db.php:33, :39
$table_prefix = isset($_POST['table_prefix']) ? safe_install_string_check($_POST['table_prefix']) : '';
...
if (preg_match("/[^0-9a-z_]+/i", $table_prefix) ) {
die('... TABLE명 접두사는 영문자, 숫자, _ 만 입력하세요. ...');
}
/[^0-9a-z_]+/i에 걸리면 즉시 die한다. 따옴표도, .도, (도 통과하지 못한다. 그런데 바로 옆에 있는 쇼핑몰(영카트) 접두사 g5_shop_prefix에는 같은 검증이 없다.
// install/install_db.php:48
$g5_shop_prefix = isset($_POST['g5_shop_prefix']) ? safe_install_string_check($_POST['g5_shop_prefix']) : 'yc5_';
같은 역할, 같은 "테이블 접두사"인데 한쪽에만 화이트리스트가 있다. 결국 g5_shop_prefix가 믿는 방어선은 safe_install_string_check() 하나뿐이다.
What the Blacklist Actually Blocks
그 하나뿐인 방어선을 뜯어본다.
// install/install.function.php:25
function safe_install_string_check( $str, $is_json=false ) {
$is_check = false;
// (1) '); 뒤에 위험 함수가 바로 붙는 패턴
if(preg_match('#\);(passthru|eval|pcntl_exec|exec|system|popen|fopen|fsockopen|file|file_get_contents|readfile|unlink|include|include_once|require|require_once)\s?#i', $str)) {
$is_check = true;
}
// (2) $_GET[...] / $_POST[...] / $_REQUEST[...] ) 패턴
if(preg_match('#\$_(get|post|request)\s?\[.*?\]\s?\)#i', $str)){
$is_check = true;
}
if($is_check){
die("입력한 값에 안전하지 않는 문자가 포함되어 있습니다. 설치를 중단합니다.");
}
return array_map_deep('stripslashes', $str); // 통과하면 stripslashes만 하고 반환
}
블랙리스트다. 그리고 이 필터가 가정하는 공격의 모양은 둘뿐이다.
); 바로 뒤에 system/exec/eval 같은 함수가 오는 경우 — 기존 구문을 끊고 새 명령을 호출하는 statement(구문 종결)형.
$_GET[...] 뒤에 )가 붙는 형태의 슈퍼글로벌 참조.
문제는 정작 이 입력이 실제로 코드로 이어지는 경로가 그 두 패턴 어디에도 걸리지 않는다는 데 있다. 블랙리스트가 가정한 공격의 모양과 실제로 성립하는 공격의 모양이 다르기 때문이다. 더구나 차단이 특정 함수 이름의 나열에 의존하는 방식이라, 그 목록을 벗어나는 경우까지 막지는 못한다. 그래서 방어가 통째로 비껴간다.
통과 뒤 마지막에 stripslashes가 한 번 돌지만, 이는 백슬래시에만 작용하는 처리라 이런 유형의 주입을 걸러내는 것과는 무관하다.
From String to Code
통과한 g5_shop_prefix는 설치 후반부에서 data/dbconfig.php의 G5_SHOP_TABLE_PREFIX 상수 값으로 기록된다(install_db.php:624). 이 기록은 if($g5_shop_install) 블록(:621) 안에 있어, 이 지점에 도달하려면 쇼핑몰(영카트) 설치가 함께 진행되는 조건이어야 한다.
여기서 흥미로운 비대칭이 하나 보인다. DB 접속 정보 같은 다른 입력값은 설치 코드가 설정 파일로 쓸 때 이스케이프 처리를 거치는데, g5_shop_prefix에는 그 처리가 빠져 있다. 그래서 검증을 통과한 비정상 입력이 "그냥 저장되는 문자열"이 아니라 "파일을 불러올 때 코드로 해석되는 값"으로 남을 수 있다. 어떤 문맥에 어떻게 놓여 코드가 되는지, 구체적인 형태는 공개하지 않는다.
Why the SQL Phase Doesn't Kill It
여기서 한 번 막힐 법하다. 쇼핑몰 설치는 설정 파일을 쓰기 전에, g5_shop_prefix를 테이블명으로 써서 테이블을 만들고 INSERT를 돌린다.
// install/install_db.php:124, :381
$file = preg_replace('/`g5_shop_([^`]+`)/', '`'.$g5_shop_prefix.'$1', $file); // 덤프의 g5_shop_ 접두사 치환 → CREATE 문에 반영
...
$sql = " insert into `{$g5_shop_prefix}default` set ... "; // 치환된 같은 접두사로 INSERT
이 CREATE와 INSERT는 전부 sql_query($sql, true, ...)로 실행되는데, 두 번째 인자가 참이면 쿼리가 하나라도 실패하는 순간 그대로 die 한다(lib/common.lib.php). 그래서 처음엔, 검증을 통과한 비정상 접두사 탓에 CREATE든 INSERT든 어딘가에서 SQL이 깨져 설정 파일을 쓰기도 전에 죽을 거라고 예상했다. 그런데 죽지 않는다. 이유는 그 접두사가 들어가는 자리가 전부 백틱으로 감싸인 테이블명이기 때문이다. 접두사 치환을 거치면, 테이블을 만드는 CREATE도 값을 넣는 INSERT도 똑같이 이런 모양이 된다.
CREATE TABLE `<주입된 접두사>default` ( ... );
INSERT INTO `<주입된 접두사>default` SET ...
MySQL/MariaDB에서 백틱으로 감싼 식별자는 백틱 자신을 뺀 거의 모든 문자를 리터럴 문자로 받아들인다. 그 특수문자들이 SQL 문법을 깨는 게 아니라, 그저 "괴상하지만 멀쩡한 테이블 이름"의 일부가 되는 것이다. 그래서 CREATE는 그 이름의 테이블을 실제로 만들고, 뒤따르는 INSERT는 같은 이름을 찾아 값을 넣는다. 어느 쪽도 에러를 내지 않으니 die도 걸리지 않고, 흐름은 끊기지 않은 채 :624의 fwrite까지 간다.
여기서 눈여겨볼 건 시점이다. 이 단계의 $g5_shop_prefix는 PHP 입장에서 아직 평범한 문자열일 뿐이고, 그것이 코드로 "평가"되는 건 오직 나중에 dbconfig.php가 include 될 때다. 문제는 바로 이 두 지점의 시점 차이에서 성립한다.
Putting It Together
구체적인 요청 경로나 파라미터 조합은 생략하고, 지금까지의 분석을 하나의 흐름으로 정리하면 이렇게 된다.
입력값 검증 누락
— g5_shop_prefix에는 바로 옆 table_prefix와 달리 화이트리스트 검증이 없고(install_db.php:48),
유일한 방어선인 블랙리스트도 특정 공격 모양만 가정해, 실제로 성립하는 다른 형태는 놓친다(install.function.php:25).
⇩
설정 파일에 안전하지 않은 값 기록
— 검증을 통과한 값이 이스케이프 없이 data/dbconfig.php에 PHP 코드 형태로 기록된다(install_db.php:624).
중간의 SQL 단계는 그 값이 백틱 식별자 자리에 들어가는 덕에 흐름을 끊지 않고 지나간다(install_db.php:124/381).
⇩
파일을 불러오는 과정에서 코드 실행
— 이후 일반 페이지 요청이 공통 진입점을 거치며 dbconfig.php를 include 하고(common.php:156),
그 순간 설정 파일에 남아 있던 값이 코드로 평가된다 → 웹 서버 권한으로 실행.
세 지점 가운데 어느 하나라도 제대로 막혔다면 성립하지 않았을 흐름이, 검증 누락 → 안전하지 않은 기록 → include 시 평가로 한 줄에 이어진다.
검증과 실행 권한
재현은 전적으로 로컬 자체 테스트 환경(127.0.0.1)에서, 설치 전 상태의 v5.6.26을 대상으로만 수행했다. 결과만 적으면, 취약 버전에서는 앞서 따라온 흐름대로 주입한 값이 dbconfig.php에 코드로 기록된 뒤 일반 페이지가 로드되는 과정에서 그대로 평가되어 실행되는 것을 확인했고, 수정 버전(v5.6.27 이상)에서는 같은 값이 설치 단계의 입력값 검증(화이트리스트)에 걸려 차단되어 dbconfig.php에 아무것도 기록되지 않는 것을 확인했다. 운영 중인 사이트 보호를 위해, 구체적인 요청·명령 예시와 단계별 재현 절차, 사용한 페이로드는 이 글에서 공개하지 않는다.
실행되는 명령은 PHP를 구동하는 프로세스(웹 서버 모듈 또는 PHP-FPM)의 권한을 그대로 따른다. 취약점 자체가 권한을 올려 주는 것은 아니며, 그때그때 PHP 프로세스가 가진 권한 안에서 동작한다. 만약 어떤 환경에서 이것이 루트 권한으로 실행된다면, 그것은 취약점의 속성이 아니라 웹 서버/PHP를 루트로 구동한 그 환경의 구성 때문이다(일부 Docker 구성 등).
Impact
설치 전·설치 미완료 상태의 인스톨러가 외부에 노출되면, 인증 없는 원격 공격자가 설치 처리를 실행해 data/dbconfig.php에 안전하지 않은 값을 코드 형태로 남길 수 있다. 그 결과 이후의 일반 페이지 요청만으로 웹 서버 권한의 명령이 실행될 수 있다.
여기서 파생되는 결과는 설정·소스와 DB 접속 정보 노출, 관리자 계정 생성·변조, 서비스 변조·장애, 나아가 내부망 추가 침투까지, PHP 실행 프로세스의 권한과 서버 구성에 따라 광범위한 후속 피해로 이어질 수 있다. 다만 이 영향은 모두 설정 파일이 아직 없는 설치 전·미완료 구간을 전제로 한다.
Affected Versions
언제부터 깨졌고 언제 고쳐졌는지 git 히스토리로 되짚어봤다. G5_SHOP_TABLE_PREFIX 상수를 기록하는 취약 지점과 g5_shop_prefix 검증의 등장·변경을 pickaxe로 추적하고, 각 태그의 install/install_db.php를 직접 꺼내 대조했다.
| 버전 |
상태 |
| ~ 5.4.5.5 |
해당 없음 — install_db.php에 shop 설치 로직(g5_shop_prefix)이 없음 |
| 5.4.6 (2021-06-17) |
영카트 5.4.5.5.1 통합으로 취약 코드 유입 → 취약 시작 |
| v5.6.26 (분석 대상, 2026-04-16) |
취약 (수정 직전 마지막 버전) |
| v5.6.27 (2026-05) |
패치 — g5_shop_prefix 화이트리스트 추가 (commit d5619707b) |
| v5.6.43 (최신) |
패치 유지 |
되짚어 본 결과는 이렇다.
- 5.4.5.5 이하의 그누보드5 본체 인스톨러에는 쇼핑몰 설치 로직 자체가 없었다.
g5_shop_prefix는 등장하지 않는다.
- 취약 코드는 영카트(youngcart) 쪽에서 2013년에 작성된 것으로(commit
90ee89b4f "shop 테이블명 g5_shop 으로 변경"), "그누보드5.4.5.5 + 영카트5.4.5.5.1 = 그누보드5.4.6" 통합과 함께 5.4.6에서 본체로 유입됐다. 이 버전부터 취약하다.
g5_shop_prefix를 거르던 safe_install_string_check는 2017년 설치 RCE 패치(commit 277ce801e, 커밋 메시지상 17-0524,525)로 들어온 블랙리스트다. 과거의 설치 RCE를 막으려고 넣은 이 블랙리스트가, 나중에 합쳐진 shop prefix 경로에서 그대로 뚫린 셈이다.
- 이 문제는 v5.6.27에서 수정됐다(commit
d5619707b "[security] 인스톨러 g5_shop_prefix PHP 코드 인젝션(RCE) 수정", 2026-05-26). 패치는 table_prefix와 똑같은 화이트리스트 한 줄을 g5_shop_prefix에 더한 것이다.
if (preg_match("/[^0-9a-z_]+/i", $g5_shop_prefix)) {
die('... 쇼핑몰 TABLE명 접두사는 영문자, 숫자, _ 만 입력하세요. ...');
}
즉 영향 구간은 5.4.6 ~ 5.6.26이고, v5.6.27부터 이 글에서 다룬 취약점이 수정되었으며, 최신 버전에서는 동일한 입력이 이 검증에 걸려(쇼핑몰 TABLE명 접두사는 영문자, 숫자, _ 만 입력하세요.) dbconfig.php에 주입되지 않는 것을 확인했다.
Fix
근본 ① — 입력값 화이트리스트. g5_shop_prefix에도 table_prefix와 같은 검증을 건다. 공식 패치(v5.6.27)가 택한 방식도 정확히 이것이다.
if (preg_match("/[^0-9a-z_]+/i", $g5_shop_prefix)) {
die("쇼핑몰 TABLE명 접두사는 영문자, 숫자, _ 만 입력하세요.");
}
근본 ② — 출력 지점에서 안전하게 직렬화. 어떤 입력도 코드로 해석되지 않도록 var_export()로 쓴다. 블랙리스트가 아니라 "출력 인코딩"을 기본값으로 삼는 것이 핵심이다.
fwrite($f, "define('G5_SHOP_TABLE_PREFIX', ".var_export($g5_shop_prefix, true).");\n\n");
이용자(운영자) 안내. 그누보드5를 운영 중인 이용자라면 다음을 권한다.
- 그누보드5를 v5.6.27 이상(가능하면 최신 릴리스) 으로 업데이트해 이번 취약점의 수정을 적용한다. 화이트리스트가 더해진 버전에서는 같은 입력이 설치 단계에서 바로 차단된다.
- 설치를 진행하는 동안에는
install/에 외부 접근이 닿지 않도록 제한한다(로컬·사내망에서만 설치, 설치 중에만 IP 제한 공개 등). 설치를 시작했다가 중단한 경우에도 미완료 상태로 방치하지 말고 접근을 막는다.
- (추가 권고) 설치가 끝나면
install/ 디렉터리를 즉시 삭제하거나 접근을 차단한다.
- PHP를 최신 버전으로 업데이트한다(관련 PHP CVE 대응 포함). 단, PHP 업데이트가 그누보드의 해당 패치를 대신하는 것은 아니다.
마치며
바로 옆 파라미터에는 걸려 있는 화이트리스트가 g5_shop_prefix 하나에만 빠져 있었고, 방어로 둔 블랙리스트는 특정한 공격 모양만 가정하느라 실제로 성립하는 다른 형태를 놓쳤다. 결국 사용자 입력을 "코드가 실행되는 파일"에 문자열로 끼워 넣는 구조 자체가 문제의 뿌리였다. 그래서 해법도 거창할 필요가 없다. 출력 지점의 안전한 직렬화 한 줄이면 상당 부분 닫힌다.
운영자 입장에서 가장 먼저 할 일은 분명하다. 그누보드5를 v5.6.27 이상(가능하면 최신 릴리스)으로 올려 이번 취약점의 수정을 적용하고, 설치 전·설치 미완료 상태에서는 인스톨러가 외부에 노출되지 않도록 접근을 제한하는 것이다.
본 취약점은 아래 GitHub 이슈로 이미 제보를 완료했다.
→ https://github.com/gnuboard/gnuboard5/issues/377#issuecomment-4436735080
본 글의 분석과 검증은 전적으로 로컬 자체 테스트 환경(127.0.0.1)에서만 수행했으며, 실제 운영 사이트의 주소나 민감 정보는 일절 포함하지 않는다. 본 취약점은 그누보드 측에 제보되어 v5.6.27에서 수정되었고, 본 글은 벤더와의 협의에 따라 작성되었다. 최근 AI·자동화 도구를 이용한 무차별 공격 동향과 전담 관리자 없이 운영되는 사이트가 많은 현실을 고려해, 구체적인 공격용 페이로드·실행 명령·요청 예시·단계별 재현 절차 및 공격 확장 기법은 의도적으로 제외했다. 이 글의 목적은 취약점의 이해와 방어를 돕기 위한 것으로, 허가되지 않은 시스템에 대한 테스트나 공격에 악용해서는 안 된다.