FlawOpen

flawopen.com/Incidents/equifax-struts-ognl-rce-july-2017

● Equifax · CVE-2017-5638 · July 2017 · CVSS 9.8 · 심각
보안 연구 · FlawOpen

Equifax 침해: 거의 1억 5천만 명 데이터 영향

CVE-2017-5638은 패치되지 않은 Equifax Struts 포털에서 Content-Type 헤더를 통한 OGNL 주입을 허용했습니다. 공격은 약 76일간 이어졌고 거의 1억 5천만 명의 데이터가 영향을 받았습니다.[1], [4]

💡 알기 쉬운 설명 (ELI5)

배송 소프트웨어가 거부된 배송 라벨을 오류 메시지에 넣는다고 상상해 보세요. 그 메시지가 라벨을 실행 가능한 지시문 템플릿으로 해석하면, 보낸 사람은 잘못된 주소를 알리는 데 그치지 않고 소프트웨어가 지시를 따르게 할 수 있습니다. Struts 패치는 이미 나와 있었지만 Equifax는 오래된 관리자 목록에 공지를 보냈고 네트워크 검사도 포털의 취약점을 탐지하지 못했습니다.[1], [4]

핵심 개념 및 용어

OGNL
Object-Graph Navigation Language. Struts가 Java 객체 속성을 읽고 설정할 때 쓰는 식 언어입니다. 공격자의 텍스트를 OGNL로 평가하면 공격자의 코드를 실행하게 됩니다.[1], [6]
Jakarta 멀티파트 파서
파일 업로드를 처리하는 Struts 구성 요소입니다. 잘못된 Content-Type을 거부할 때 이후 평가되는 오류 메시지를 만들었습니다.[1], [3], [2]
식 인젝션(CWE-917)
신뢰할 수 없는 입력이 인터프리터가 평가하는 식 안으로 들어가, 데이터가 명령이 되는 것입니다.[6]
자산 목록
어떤 시스템에서 어떤 소프트웨어의 어떤 버전이 돌아가는지 기록한 것입니다. 이것이 없으면 패치 공지를 필요한 장비와 연결할 수 없습니다.[4]

프레임워크 매핑

사고 타임라인

2017-03-07

Apache가 보안 공지 S2-045와 수정된 Struts 2.3.32, 2.5.10.1을 발표합니다.[1]

2017-05-13

Equifax는 이후 GAO에 별도의 침입이 5월 13일 시작되어 공격자들이 그때부터 개인정보를 추출했다고 설명했습니다. 그보다 앞서 무단 접근이 있었지만 당시 데이터는 탈취되지 않았습니다.[4]

2017-07-29

Equifax가 트래픽 검사 장비의 만료된 인증서를 갱신한 뒤 수상한 트래픽을 발견합니다.[4]

2017-09-07

Equifax가 침해를 공개합니다. 이후 GAO는 미국 내 최소 1억 4,550만 명과 미국 밖 약 100만 명의 데이터가 영향을 받았다고 보고합니다.[4]

단계별 공격 실행 흐름

단계 1

조작된 Content-Type 헤더

공격자는 Content-Type 헤더에 미디어 타입 대신 OGNL 식을 넣은 업로드 요청을 보냅니다.[1]

단계 2

오류 메시지에 헤더가 담긴다

Jakarta 파서는 헤더를 거부하고, 헤더의 원문을 포함한 현지화된 오류 메시지를 만듭니다.[1], [3], [2]

단계 3

메시지가 평가된다

Struts가 이 메시지 안의 식을 평가하므로, 공격자의 OGNL이 웹 애플리케이션 권한으로 Java 코드로 실행됩니다.[1], [3], [2], [6]

단계 4

조용하고 긴 데이터 유출

공격자들은 저장소에서 발견한 자격 증명을 이용해 포털 관련 데이터베이스 3개에서 추가로 포털과 무관한 48개 데이터베이스로 접근을 넓혔습니다. GAO에 따르면 약 9,000건의 쿼리를 실행했고 공격은 약 76일 이어졌습니다. 만료된 인증서 때문에 암호화 트래픽을 검사할 수 없었습니다.[4]

소스 코드 비교: 취약한 구현 vs 보안 패치

설명용 Java 코드이며 Struts 소스가 아닙니다. 2.3.32와 2.5.10.1의 실제 수정은 멀티파트 오류를 만드는 방식을 바꿨습니다. 강화된 버전은 고정된 메시지 키를 쓰고 헤더는 위치 인자로만 넘기므로, 텍스트로 서식화될 뿐 식의 일부가 되지 않습니다. 또 미디어 타입처럼 보이지 않는 값은 자리 표시자로 바꾸므로, 나중에 메시지를 바꾸더라도 헤더가 평가되는 문자열로 돌아올 수 없습니다.[3], [2], [6]

취약한 구현
import com.opensymphony.xwork2.TextProvider;
import javax.servlet.http.HttpServletRequest;

public final class UploadErrors {
    private final TextProvider text;   // resolves messages and evaluates expressions in them

    public UploadErrors(TextProvider text) {
        this.text = text;
    }

    public String describe(HttpServletRequest request) {
        String contentType = request.getHeader("Content-Type");
        // 원시 헤더가 평가되는 메시지의 일부가 됩니다.
        return text.getText("Invalid content type: " + contentType);
    }
}
보안 강화 패치
import com.opensymphony.xwork2.TextProvider;
import java.util.Locale;
import java.util.regex.Pattern;
import javax.servlet.http.HttpServletRequest;

public final class UploadErrors {
    private static final Pattern MEDIA_TYPE = Pattern.compile(
        "^[a-z]+/[a-z0-9.+-]+(;\\s*[a-z0-9-]+=[a-z0-9._-]+)*$");
    private final TextProvider text;

    public UploadErrors(TextProvider text) {
        this.text = text;
    }

    public String describe(HttpServletRequest request) {
        String contentType = request.getHeader("Content-Type");
        boolean wellFormed = contentType != null && contentType.length() <= 256
            && MEDIA_TYPE.matcher(contentType.toLowerCase(Locale.ROOT)).matches();
        // 고정 메시지 키를 사용하고 헤더는 위치 인수로만 전달합니다.
        // 이때도 미디어 타입 형식일 경우에만 전달합니다.
        return text.getText("upload.invalid.content.type",
            new String[] { wellFormed ? contentType : "(malformed)" });
    }
}

엔지니어링 및 시스템 보안 강화 체크리스트

출처