infiltr8 Lab
Home/ CVE/ 웹 애플리케이션/ SuiteCRM AOR_Scheduled_Reports PHP 객체 주입
CVE · 웹 애플리케이션 · 원격코드실행 · CVE-2022-23940
심층 분석 연구 분석

CVE-2022-23940 (SuiteCRM 원격코드실행) 분석

오픈소스 CRM SuiteCRM의 '예약 리포트(Scheduled Reports)' 모듈이, 이메일 수신자 목록을 저장하는 필드 하나를 검증 없이 역직렬화(unserialize)해 PHP 객체 주입(PHP Object Injection)으로 이어지는 CVSS 8.8의 원격코드실행 취약점입니다(CVE-2022-23940, CWE-502). 다만 원격·비인증이 아니라, 예약 리포트를 만들 수 있는 인증된 계정이 전제조건입니다. 이 글은 벤더 권고문·NVD·SuiteCRM 오픈소스를 취약 버전(7.12.4)과 패치 버전(7.12.5) 태그 기준으로 직접 대조해, '왜 배열 검사가 있었는데도 뚫렸는지' 프레임워크 차원의 근본원인을 재구성합니다.

이 글의 코드 발췌(modules/AOR_Scheduled_Reports/AOR_Scheduled_Reports.php의 save()·get_email_recipients()·parseRecipients(), modules/AOR_Scheduled_Reports/vardefs.php, include/formbase.php의 populateFromPost(), include/SugarFields/Fields/Base/SugarFieldBase.php의 save())는 salesagility/SuiteCRM 오픈소스를 취약 버전 태그(v7.12.4)와 패치 버전 태그(v7.12.5)에서 raw.githubusercontent.com으로 직접 받아 파일·라인 단위로 byte-level 대조했습니다. 패치 diff는 실제 패치 커밋(e93b269f637de313f45b32c58cef5ec012a34f58, 부모 e9414a13b03d89d38eed34723f96afd4471c484e)을 GitHub API/.patch 원문으로 직접 확인했습니다. CVSS·CWE·영향 버전 범위는 NVD REST API(CVE-2022-23940)를 직접 질의해 확인했습니다. 이 취약점의 핵심 인과관계 — 모듈 자체의 배열 타입 검사(is_array)가 있었는데도 뚫린 이유가 '프레임워크의 범용 필드 자동할당(populateFromPost)이 그 검사보다 먼저 실행되어 원시 값을 이미 bean 속성에 써넣기 때문'이라는 서술 — 은 저희가 이 네 개 파일의 실제 소스를 직접 대조해 계산·재구성한 것이며, 공개된 3자 분석 글(github.com/crac-learning/CVE-analysis-reports의 PDF, manuelz120의 PoC README, medium.com/@_crac 글)에는 이 프레임워크 단계까지의 설명이 없습니다 — 저희는 이 글들을 공격이 존재한다는 사실과 대략적인 요청 형태(email_recipients를 배열 대신 문자열로 보낸다)를 확인하는 방향 참고용으로만 훑었고, 문장·구조·다이어그램을 옮기지 않았습니다. 다만 저희도 실제 동작하는 PHP 가젯 체인 페이로드를 직접 만들어 실행·재현하지는 않았습니다(무해화, meta.reproduced=false) — 그 한계는 §패치완전성 평가·본문 배지에 명시합니다.

infiltr8 team 직접 분석 #SuiteCRM#PHP객체주입#insecure-deserialization#RCE
무엇SuiteCRM의 예약 리포트(AOR_Scheduled_Reports) 모듈이 이메일 수신자 목록(email_recipients) 필드를 검증 없이 unserialize()해, 인증된 사용자가 PHP 객체 주입으로 원격코드실행에 이를 수 있는 취약점입니다.
원리모듈 자체 코드는 이 필드가 배열일 때만 안전하게 재직렬화하는 방어 검사를 갖고 있었지만, 그 검사가 실행되기 전에 프레임워크의 범용 필드 자동할당(populateFromPost())이 공격자가 보낸 원시 문자열을 이미 bean 속성에 써넣습니다 — 뒤이은 배열 검사는 그 값을 건드리지 못하고 통과시킵니다.
핵심패치는 unserialize() 자체를 없애지 않고, 저장 시점마다 값을 무조건 초기화한 뒤 입력이 배열일 때만 화이트리스트로 재구성하는 방식으로 우회 경로를 막았습니다. CVSS 8.8(High), CWE-502이며, 실제 안전 버전은 SuiteCRM 7.12.5입니다(작업 지시 문구의 '7.12.6'과 다릅니다 — §자가점검에서 정정).

01개요

SuiteCRM은 SugarCRM Community Edition을 기반으로 한 오픈소스 CRM으로, 리포트를 주기적으로 자동 실행해 지정된 수신자에게 이메일로 보내는 '예약 리포트(Scheduled Reports, 모듈명 AOR_Scheduled_Reports)' 기능을 제공합니다. 이 모듈은 수신자 목록(이메일 주소·특정 사용자·보안그룹·역할 등 여러 형태를 지원)을 데이터베이스의 longtext 컬럼 하나에 넣기 위해, PHP 배열을 serialize()로 직렬화하고 base64_encode()로 인코딩해 저장하는 자체 방식을 씁니다.

CVE-2022-23940은 이 저장·복원 과정에서 발생합니다. 저장 시(save()) 모듈 코드는 '입력이 배열일 때만' 안전하게 재직렬화하는 방어 검사를 갖고 있지만, 이 검사가 실행되기 전에 SuiteCRM 프레임워크의 범용 폼 처리 로직이 요청 파라미터를 이미 bean 속성에 그대로 써넣습니다. 공격자가 배열이 아닌 원시 문자열(자신이 만든 base64(serialize(...)) 페이로드)을 보내면 방어 검사는 조용히 통과되고, 이 원시 문자열이 그대로 데이터베이스에 저장됩니다. 나중에 리포트가 실행되거나 조회될 때 복원 함수(get_email_recipients())가 저장된 값의 출처를 확인하지 않고 무조건 unserialize(base64_decode(...))를 호출하면서, 공격자가 고른 PHP 객체 그래프가 그대로 복원됩니다(PHP Object Injection). SuiteCRM이 의존하는 Composer 라이브러리(Monolog 등)에는 이 복원 과정에서 매직 메서드를 통해 임의 코드를 실행시킬 수 있는 '가젯(gadget)' 클래스가 이미 포함돼 있어, 이 지점이 곧 원격코드실행으로 이어집니다.

이 취약점은 비인증 원격공격이 아닙니다. CVSS 벡터의 PR:L(Privileges Required: Low)이 뜻하듯, 예약 리포트를 생성·수정할 수 있는 권한을 가진 계정으로 로그인한 상태가 전제조건입니다 — 관리자 권한까지는 필요 없고, 해당 모듈에 편집 권한이 있는 일반 사용자면 충분합니다. NVD 설명 자체도 "Authenticated users with access to the Scheduled Reports module"이라고 명시합니다.

CVSS: 8.8 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H, NVD) · CWE-502(Deserialization of Untrusted Data)

발견자는 GitHub 사용자 manuelz120이며, 2022-01-24 벤더에 신고, 2022-01-25 SalesAgility가 확인, GHSA-26v2-hwwj-jjg2로 공개됐습니다(패치 커밋 날짜 2022-03-01, NVD 발행일 2022-03-10). SuiteCRM 7.x 라인뿐 아니라 재작성판인 SuiteCRM-Core 8.x 라인도 동일 근본원인으로 영향을 받아 8.0.4에서 같이 수정됐습니다 — 별도 CVE로 쪼개지지 않고 이 CVE 하나가 두 라인 모두를 대표합니다.

02영향받는 버전

대상취약 버전안전 버전
SuiteCRM (7.x 라인)7.12.5 미만 전체(NVD CPE 설정 기준). GHSA·NVD 설명문 텍스트는 "through 7.12.1"이라고 적어 CPE 설정과 어긋나는데, 벤더 자체 릴리스 노트·실제 패치 커밋이 붙은 위치가 7.12.5이므로 이 글은 CPE 설정(<7.12.5)을 정본으로 씁니다.7.12.5 이상
SuiteCRM-Core (8.x 라인, 재작성판)8.0.0 ~ 8.0.3 (8.0.4 미만)8.0.4 이상

03배경 — longtext 컬럼 하나에 구조화된 배열을 욱여넣는 자체 (역)직렬화, 그리고 그 앞을 지나는 프레임워크의 범용 폼 처리

AOR_Scheduled_Reports의 vardefs.php는 email_recipients 필드를 특별한 커스텀 타입 없이 그냥 type => 'longtext'로 선언합니다 — SuiteCRM/SugarCRM 프레임워크 입장에서는 이 필드가 '평범한 긴 텍스트 컬럼'과 다를 게 없습니다. 하지만 이 모듈의 수신자 목록은 이메일 주소·지정 사용자·보안그룹·역할이 섞인 구조화된 배열이라서, 모듈 개발자는 이걸 하나의 텍스트 컬럼에 넣기 위해 base64_encode(serialize($array))라는 자체 인코딩을 save()·get_email_recipients() 두 함수에 직접 구현해 뒀습니다.

문제는 이 필드가 '모듈 자체 로직만 채우는 값'이라는 사실이 vardefs.php의 타입 선언만으로는 프레임워크에 전달되지 않는다는 점입니다. SuiteCRM/SugarCRM 계열 프레임워크는 레코드를 저장할 때 include/formbase.php의 populateFromPost()라는 범용 함수를 거칩니다 — 이 함수는 모듈의 vardefs.php에 선언된 모든 필드를 순회하며, 필드 타입에 맞는 SugarField 핸들러를 찾아 그 핸들러의 save()를 호출해 $_POST 값을 bean 속성에 자동으로 채워 넣습니다. longtext 타입은 별도 커스텀 핸들러가 없으면 SugarFieldBase의 기본 save()가 처리하는데, 이 기본 구현은 값이 배열이든 문자열이든 형식을 가리지 않고 그대로(트림 정도만 거쳐) 대입합니다.

즉 이 시점에서 이미 $_POST['email_recipients']가 무엇이든 $bean->email_recipients에 그대로 들어갑니다. 그 뒤에야 모듈 자체의 AOR_Scheduled_Reports::save()가 실행되며 '이 값이 배열인지' 검사하는데, 이 검사가 통과하지 못하더라도 앞서 이미 대입된 원시 값을 되돌릴 방법이 이 코드에는 없었습니다 — 다음 절에서 이 순서를 정확히 짚습니다.

04취약점 발생 원인

이 취약점은 세 겹으로 겹쳐서 성립합니다 — 프레임워크가 모든 필드를 무조건 신뢰하고 자동으로 채워 넣는 것(W1), 모듈의 방어 검사가 '조건에 안 맞으면 그냥 통과'하는 fail-open 방식인 것(W2), 그리고 최종적으로 저장된 값의 출처를 다시 확인하지 않고 무조건 역직렬화하는 것(W3)입니다. 셋 중 하나만 없었어도 이 체인은 끊어졌습니다.

1"vardefs에 선언된 필드는 전부 폼 입력을 그대로 받는다" — 범용 필드 자동할당

핵심

include/formbase.php::populateFromPost()가 모듈의 vardefs.php에 선언된 모든 필드를 순회하며, 필드 타입에 맞는 SugarField 핸들러의 save()를 호출해 $_POST 값을 검증 없이 bean 속성에 대입합니다 — email_recipients도 예외가 아닙니다.

populateFromPost()는 $focus->field_defs(모듈의 vardefs.php가 정의)를 순회하며, 각 필드의 type에 해당하는 SugarField 핸들러 객체를 얻어 그 핸들러의 save($focus, $_POST, $field, $def, $prefix)를 호출합니다. email_recipients는 vardefs.php에서 커스텀 타입 없이 'type' => 'longtext'로만 선언돼 있어, 별도 핸들러가 없으면 기본 클래스 SugarFieldBase의 save()가 처리를 맡습니다.

foreach ($focus->field_defs as $field=>$def) { if ($field == 'id' && !empty($focus->id)) { // Don't try and overwrite the ID continue; } $type = !empty($def['custom_type']) ? $def['custom_type'] : $def['type']; $sf = $sfh::getSugarField($type); if ($sf != null) { $sf->save($focus, $_POST, $field, $def, $prefix); } else { $GLOBALS['log']->fatal("Field '$field' does not have a SugarField handler"); } }

SuiteCRM 오픈소스 실제 소스(v7.12.4 태그) — include/formbase.php · populateFromPost()의 필드 순회 루프. 126~128행이 필드 타입별 핸들러를 호출해 $_POST 값을 검증 없이 대입하는 지점입니다.

SugarFieldBase::save()는 $params[$prefix.$field]가 설정돼 있기만 하면 (트림 가능한 타입이면 trim()을 거쳐) 그 값을 그대로 $bean->$field에 대입합니다 — 배열인지 문자열인지, 모듈이 기대하는 '직렬화된 형식'인지는 전혀 가리지 않습니다. 즉 공격자가 email_recipients 파라미터로 무엇을 보내든, 모듈 자체의 save()가 실행되기도 전에 이미 $bean->email_recipients에는 그 원시 값이 들어가 있습니다.

왜 이렇게 됐나이 함수는 SuiteCRM의 모든 모듈, 모든 필드에 공통으로 쓰이는 범용 폼 저장 경로입니다. '이 필드는 화면 폼이 아니라 모듈 자체 로직만 채워야 한다'는 개별 필드의 특수한 신뢰 계약을 프레임워크 차원에서 표현할 방법이 vardefs.php의 타입 선언만으로는 없습니다 — longtext는 그냥 긴 텍스트일 뿐, '직접 폼 입력 금지'라는 신호가 아닙니다.

2"배열이 아니면 그냥 넘어간다" — 실패해도 안전한 기본값으로 되돌리지 않는 방어 검사

핵심

AOR_Scheduled_Reports::save()는 $_POST['email_recipients']가 배열일 때만 안전하게 재직렬화하고, 배열이 아니면 아무 조치도 하지 않습니다 — W1이 이미 대입해 둔 원시 문자열이 그대로 남아 parent::save()로 저장됩니다.

패치 전 save()는 isset($_POST['email_recipients']) && is_array($_POST['email_recipients'])일 때만 base64_encode(serialize($_POST['email_recipients']))로 안전하게 재직렬화해 $this->email_recipients에 씁니다. 공격자가 배열 형식(email_recipients[email][]=... 같은 대괄호 표기) 대신 email_recipients=<임의 문자열>처럼 단일 문자열로 보내면 이 if 조건은 거짓이 되고, 블록 전체가 그냥 건너뛰어집니다.

public function save($check_notify = false) { if (isset($_POST['email_recipients']) && is_array($_POST['email_recipients'])) { $this->email_recipients = base64_encode(serialize($_POST['email_recipients'])); } return parent::save($check_notify); }

SuiteCRM 오픈소스 실제 소스(v7.12.4 태그, 패치 전) — modules/AOR_Scheduled_Reports/AOR_Scheduled_Reports.php · save(). 86행의 is_array() 검사가 실패해도 87~88행이 실행되지 않을 뿐, email_recipients를 되돌리는 조치가 없습니다.

이때 이 함수는 '조건이 안 맞으니 이 필드를 안전한 값(예: null)으로 되돌린다'는 조치를 전혀 하지 않습니다. 그 결과 W1(populateFromPost)이 이미 $this->email_recipients에 대입해 둔 공격자의 원시 문자열이 그대로 남은 채 다음 줄의 parent::save($check_notify)로 넘어가 데이터베이스에 저장됩니다. 검사는 '이 값이 안전한 형태인가'만 확인했을 뿐, '아니라면 무엇으로 대체할 것인가'는 다루지 않은 것입니다.

왜 이렇게 됐나이 코드가 답하려던 질문은 '사용자가 정상적으로 폼에서 수신자를 선택해 보냈는가'였지, '이 필드에 이미 다른 경로(W1)로 값이 채워져 있을 수 있는가'가 아니었습니다. 검사를 통과하지 못한 입력을 '무시'가 아니라 '거부하고 안전한 기본값으로 리셋'했어야 했는데, 그 차이가 이 취약점의 실질적인 방아쇠입니다.

3"저장된 값은 항상 우리가 만든 형식일 것이다" — 출처를 안 가리는 무조건적 unserialize()

핵심

get_email_recipients()는 데이터베이스에서 읽어온 email_recipients 값이 정말 자신이 serialize()한 배열인지 전혀 확인하지 않고 무조건 unserialize(base64_decode(...))를 호출합니다 — 이 지점이 최종 싱크(sink)입니다.

W1·W2를 거치면 email_recipients 컬럼에는 공격자가 원하는 임의의 바이트열(base64(serialize(가젯 객체 그래프)))이 그대로 저장될 수 있습니다. 리포트가 실행되거나(스케줄러) 상세 화면에서 조회될 때 호출되는 get_email_recipients()는 이 값의 출처·형식을 검증하는 코드가 한 줄도 없이 unserialize(base64_decode($this->email_recipients))를 실행합니다.

public function get_email_recipients() { $params = unserialize(base64_decode($this->email_recipients)); $emails = array(); if (isset($params['email_target_type'])) { // ... email_target_type/이메일 값을 조합해 수신자 목록을 구성 ... } return $emails; }

SuiteCRM 오픈소스 실제 소스(v7.12.4 태그) — modules/AOR_Scheduled_Reports/AOR_Scheduled_Reports.php · get_email_recipients(). 이 함수는 7.12.5 패치에서도 바뀌지 않았습니다 — 싱크 자체는 그대로 남아 있고, W2가 막혀 도달할 수 없게 됐을 뿐입니다.

PHP의 unserialize()는 입력 문자열이 인코딩하는 클래스의 __wakeup()·__destruct() 같은 매직 메서드를 자동으로 호출합니다. NVD 설명이 직접 지목하듯, SuiteCRM이 Composer로 가져오는 의존성(예: Monolog)에는 이런 매직 메서드 체인을 조합해 임의 코드를 실행시킬 수 있는 알려진 '가젯' 클래스가 이미 포함돼 있어(예: phpggc의 Monolog/RCE1 계열), 공격자는 자체 코드를 SuiteCRM에 추가로 심지 않고도 이미 서버에 존재하는 클래스만으로 원격코드실행에 도달할 수 있습니다.

왜 이렇게 됐나PHP 공식 문서 자체가 "신뢰할 수 없는 데이터에는 unserialize()를 쓰지 말라"고 명시할 만큼 잘 알려진 위험입니다. 여기서 이 경고가 지켜지지 않은 이유는 코드 작성자가 이 규칙을 몰라서가 아니라, '이 컬럼은 우리 자신의 save()만 채운다'는 (W1·W2 때문에 실제로는 깨져 있던) 암묵적 전제를 그대로 믿었기 때문입니다.

분석 결론

무게 중심은 W2(save()의 is_array() 검사가 fail-open인 것)입니다 — 패치가 실제로 고친 지점도 여기입니다.

W1(프레임워크의 범용 필드 자동할당)은 그 자체로는 결함이 아닙니다 — SuiteCRM/SugarCRM 계열 프레임워크 전체가 이 방식으로 폼을 처리하며, 이 CVE로 바뀌지 않았습니다. 다만 W1이 '이 필드는 모듈 로직만 채운다'는 모듈 개발자의 전제를 조용히 깨뜨릴 수 있다는 사실을 W2가 고려하지 못했습니다.

W3(무조건적 unserialize())도 이 패치에서 바뀌지 않았습니다 — 여전히 코드 어딘가에 존재하지만, W2가 막히면서 공격자가 그 입력을 통제할 방법이 사라졌습니다. 3자 분석 글 다수가 "save()가 입력을 배열로 가정했다"고 설명하는데, 이는 표면적으로 맞지만 '왜 배열이 아닌 값이 애초에 그 자리에 도달할 수 있었는가(W1)'까지는 짚지 않습니다 — 이 글이 원본 소스 대조로 보태는 지점입니다.

05공격 과정

5.1 공격 과정 요약

공격 흐름도
공격 흐름도 (직접 작성)

5.2 상세 공격 과정

예를 들어 정상적인 저장 요청이라면 email_recipients[email_target_type][]=Email Address·email_recipients[email][]=a@b.com처럼 대괄호 표기로 배열이 전송됩니다. 공격자는 대신 email_recipients=<base64 문자열>처럼 대괄호 없는 단일 값을 보내 이 필드를 문자열로 만듭니다.

이 요청이 index.php?module=AOR_Scheduled_Reports&action=Save로 들어가면, 저장 컨트롤러가 include/formbase.php::populateFromPost()를 호출해 vardefs.php에 선언된 모든 필드를 채웁니다 — email_recipients도 예외 없이 순회 대상이므로, 이 시점에 공격자의 문자열이 그대로 $bean->email_recipients에 들어갑니다.

그 다음 AOR_Scheduled_Reports::save()가 실행되지만, is_array($_POST['email_recipients'])가 거짓이므로 재직렬화 블록은 건너뛰어지고 이 함수는 email_recipients를 전혀 건드리지 않은 채 parent::save()를 호출합니다 — 결과적으로 공격자의 원시 문자열이 정상 저장 경로를 그대로 통과해 데이터베이스에 저장됩니다.

리포트가 실행되거나 상세 화면에서 열릴 때 get_email_recipients()가 이 값을 unserialize(base64_decode(...))로 복원합니다. 여기서부터 실제 원격코드실행에 이르는 구체적인 가젯 체인 조립(어떤 클래스를 어떤 속성값으로 연결해야 매직 메서드가 셸 명령까지 도달하는지)은 phpggc 같은 공개 도구가 자동화해 제공하는 영역이며, 이 글은 그 조립 절차나 완성된 페이로드 바이트를 싣지 않습니다(무해화).

5.3 미흡점 ↔ 공격 흐름 매핑

미흡점(§4)발화 지점(§5.2)여는 프리미티브→ 다음으로 잇는 것
W1 — 범용 필드 자동할당(populateFromPost)저장 요청 처리 초기 단계(§8.2 2단계)검증 안 된 원시 문자열이 bean 속성에 확정모듈 자체 save()가 이미 확정된 값을 인지 못함
W2 — is_array() 검사의 fail-openAOR_Scheduled_Reports::save() 실행(§8.2 3단계)검사 실패 시에도 email_recipients가 초기화되지 않음원시 문자열이 그대로 DB에 영속화
W3 — 무조건적 unserialize()get_email_recipients() 호출(§8.2 4단계)공격자가 고른 객체 그래프가 그대로 복원매직 메서드 연쇄 → 원격코드실행

06취약점 패치

modules/AOR_Scheduled_Reports/AOR_Scheduled_Reports.php·save() → parseRecipients()로 분리·커밋 e93b269f637de313f45b32c58cef5ec012a34f58 · 부모 e9414a13b03d89d38eed34723f96afd4471c484e (Matt Lorimer, 2022-03-01 · SuiteCRM 7.12.5 릴리스에 포함)
public function save($check_notify = false) { if (isset($_POST['email_recipients']) && is_array($_POST['email_recipients'])) { $this->email_recipients = base64_encode(serialize($_POST['email_recipients'])); } $this->parseRecipients(); return parent::save($check_notify); } (중간 생략 — shouldRun()/handleTimeZone()은 이 커밋에서 공백 정리만, 로직 변경 없음) $date->modify($offset . 'second'); } /** * Parse and set recipients * @return void */ protected function parseRecipients(): void { $recipients = $_POST['email_recipients'] ?? null; unset($_POST['email_recipients'], $_REQUEST['email_recipients'], $_GET['email_recipients']); $this->email_recipients = null; if (is_array($recipients)) { $types = $recipients['email_target_type'] ?? []; $emailInfo = $recipients['email'] ?? []; $recipients = [ 'email_target_type' => $types, 'email' => $emailInfo, ]; $this->email_recipients = base64_encode(serialize($recipients)); } } }

왜§7 W2에서 지목한 자리 — is_array() 검사가 실패했을 때 아무 조치도 하지 않던 지점 — 을 정확히 겨냥한 수정입니다.

어떻게패치는 검사 로직을 parseRecipients()라는 별도 메서드로 옮기면서 세 가지를 바꿨습니다. 첫째, $_POST·$_REQUEST·$_GET에서 email_recipients를 즉시 unset()해 이후 다른 코드 경로가 원시 값을 다시 읽을 수 없게 합니다. 둘째, $this->email_recipients를 매 저장마다 무조건 null로 먼저 초기화합니다 — 이전 버전처럼 조건 불충족 시 값을 그대로 두는 대신, 일단 안전한 기본값으로 리셋합니다. 셋째, 입력이 실제로 배열일 때만 그 안에서 email_target_type·email 딱 두 키만 뽑아 새 배열을 조립해 재직렬화합니다 — 원본 배열을 그대로 직렬화하는 게 아니라 화이트리스트로 재구성하는 것이라, 설령 배열 안에 예상 밖의 키나 값이 섞여 있어도 걸러집니다. 즉 검사 실패 시 동작이 '아무 것도 안 함(fail-open)'에서 '안전한 값으로 되돌림(fail-closed)'으로 바뀐 것이 이 패치의 본질입니다.

비고§7 W3에서 지목한 get_email_recipients()의 무조건적 unserialize(base64_decode(...)) 호출 자체는 이 패치에서 바뀌지 않았습니다 — 싱크는 코드상 그대로 남아 있고, 입력 경로(W1→W2)가 봉쇄되어 현재는 도달 불가능해졌을 뿐입니다. 이 잔존 설계에 대한 평가는 §패치완전성 평가에 남깁니다.

07대응방안

실제 벤더가 배포한 안전 버전은 SuiteCRM(7.x 라인) 7.12.5 이상, SuiteCRM-Core(8.x 라인) 8.0.4 이상입니다(NVD CPE 설정·GitHub 패치 커밋 e93b269f637de313f45b32c58cef5ec012a34f58 기준). 2022-05-24 배포된 7.12.6은 이 CVE와 무관한 별도 취약점들(SuiteCRM 공식 릴리스 노트 기준 SQLi·접근제어·CSRF 등 여러 CVE 묶음)을 고친 이후 패치판입니다 — 이 CVE 하나만 놓고 안전 버전을 '7.12.6'으로 표기하는 것은 부정확합니다.

08왜 이게 반복되는가

이 취약점을 보면 '모듈 개발자가 배열만 가정한 실수'라고 요약하고 넘어가기 쉽지만, byte-level로 소스를 대조해 보면 진짜 흥미로운 지점은 그 앞단에 있었습니다 — 모듈이 짠 방어 검사는 정확했지만, 프레임워크가 그 검사보다 먼저 같은 값을 이미 확정해 버렸다는 것입니다. SugarCRM/SuiteCRM 계열 프레임워크의 범용 필드 자동할당(mass assignment)은 모든 vardef 필드를 동일한 방식으로 다루는 게 목적인데, 그 범용성이 '이 필드만은 모듈 자신의 특수한 (역)직렬화 계약을 따라야 한다'는 개별 필드의 예외적 요구와 충돌하는 순간 이런 취약점이 생깁니다.

패치완전성 관점에서 이 사건은 '발화 지점은 정확히 막혔지만, 근본 위험 요소(무조건적 unserialize())는 그대로 남아 있다'는 흔한 패턴을 보여줍니다. parseRecipients()가 도입한 fail-closed(항상 null로 초기화 후 화이트리스트 재구성) 방식은 이번 공격 경로를 확실히 차단했지만, get_email_recipients()는 여전히 unserialize()를 쓰고 있어 — 만약 훗날 이 필드에 도달하는 또 다른 경로가 생긴다면 같은 클래스의 취약점이 다시 열릴 수 있는 설계입니다. 근본적으로 안전한 대안(json_encode/json_decode)으로 바꾸지 않고 입구만 막은 패치입니다.

'프레임워크가 모든 필드를 무조건 신뢰해서 자동으로 채워 넣는다'는 편의 기능과, '이 필드는 우리 모듈만의 특수한 형식으로 채워야 한다'는 개별 모듈의 전제가 충돌하는 지점은 SuiteCRM 밖에서도, mass-assignment를 쓰는 다른 프레임워크(Rails·Laravel 계열의 초기 mass-assignment 취약점들과 결이 같습니다)에서도 반복되는 질문입니다 — 그래서 저희는 이 커밋 하나가 아니라 이 질문을 남깁니다.

09결론

SuiteCRM의 예약 리포트 모듈에서, 프레임워크의 범용 필드 자동할당(populateFromPost)이 모듈 자체의 배열 타입 검사(is_array)보다 먼저 실행되면서 그 검사를 사실상 무력화한 것이 근본원인입니다(W1+W2). 이렇게 저장된 검증되지 않은 값을 get_email_recipients()가 무조건 unserialize()하면서(W3) PHP 객체 주입과 원격코드실행으로 이어집니다. CVSS 8.8(High), CWE-502이며, 인증된 사용자(예약 리포트 편집 권한)가 전제조건입니다. 패치(커밋 e93b269f637de313f45b32c58cef5ec012a34f58, SuiteCRM 7.12.5 / SuiteCRM-Core 8.0.4)는 발화 지점(W2)을 fail-closed 방식으로 정확히 막았지만, 근본 싱크(W3, unserialize())는 코드상 남아 있습니다.

이 CVE의 표준판(요약본)은 아직 별도로 작성되지 않았습니다.

참고 자료

이 글의 코드 발췌(modules/AOR_Scheduled_Reports/AOR_Scheduled_Reports.php의 save()·get_email_recipients()·parseRecipients(), modules/AOR_Scheduled_Reports/vardefs.php, include/formbase.php의 populateFromPost(), include/SugarFields/Fields/Base/SugarFieldBase.php의 save())는 salesagility/SuiteCRM 오픈소스를 취약 버전 태그(v7.12.4)와 패치 버전 태그(v7.12.5)에서 raw.githubusercontent.com으로 직접 받아 파일·라인 단위로 byte-level 대조했습니다. 패치 diff는 실제 패치 커밋(e93b269f637de313f45b32c58cef5ec012a34f58, 부모 e9414a13b03d89d38eed34723f96afd4471c484e)을 GitHub API/.patch 원문으로 직접 확인했습니다. CVSS·CWE·영향 버전 범위는 NVD REST API(CVE-2022-23940)를 직접 질의해 확인했습니다. 이 취약점의 핵심 인과관계 — 모듈 자체의 배열 타입 검사(is_array)가 있었는데도 뚫린 이유가 '프레임워크의 범용 필드 자동할당(populateFromPost)이 그 검사보다 먼저 실행되어 원시 값을 이미 bean 속성에 써넣기 때문'이라는 서술 — 은 저희가 이 네 개 파일의 실제 소스를 직접 대조해 계산·재구성한 것이며, 공개된 3자 분석 글(github.com/crac-learning/CVE-analysis-reports의 PDF, manuelz120의 PoC README, medium.com/@_crac 글)에는 이 프레임워크 단계까지의 설명이 없습니다 — 저희는 이 글들을 공격이 존재한다는 사실과 대략적인 요청 형태(email_recipients를 배열 대신 문자열로 보낸다)를 확인하는 방향 참고용으로만 훑었고, 문장·구조·다이어그램을 옮기지 않았습니다. 다만 저희도 실제 동작하는 PHP 가젯 체인 페이로드를 직접 만들어 실행·재현하지는 않았습니다(무해화, meta.reproduced=false) — 그 한계는 §패치완전성 평가·본문 배지에 명시합니다.