CVE-2026-87656
Overview
Background
- UnRAR key derivation
- RAR 2.9/3.x archives derive an AES key from
(password, salt)via a fixed 262144-round SHA-1 loop (RAR 5.x uses PBKDF2), an intentionally expensive operation performed inSetKey30/SetKey50. - Per-entry salt
- RAR 2.9/3.x file headers and RAR 5.x encryption records each carry their own salt, so an archive can present a distinct salt for every entry, defeating the small
KDF3Cache/KDF5Cache(four-entry) key caches. - `SandboxedRarAnalyzer`
- the Safe Browsing component that opens downloaded
.rararchives inside a sandboxed utility (file_util) process to enumerate contents and flag executables, invoking UnRAR header parsing including header decryption. - `CryptData`
- the UnRAR object that holds cipher state and the KDF caches for a single archive-analysis pass, created once per archive.
Root Cause Analysis
When SandboxedRarAnalyzer parses an encrypted RAR archive, UnRAR calls CryptData::SetCryptKeys for each encrypted header/entry, which dispatches to SetKey30 (RAR3) or SetKey50 (RAR5). Because each entry can carry a fresh salt, the four-slot key cache misses on every entry, forcing a full, deliberately slow key derivation (262144 SHA-1 rounds per RAR3 entry) for every one of them — and this cost is paid regardless of whether the supplied password is even correct, since derivation precedes any password check. An archive with a large number of distinct-salt encrypted entries therefore multiplies this fixed per-entry cost without bound, so analysis-time work scales linearly with attacker-controlled entry count.
The fix adds a per-CryptData counter KDFCacheMisses and, on each cache miss, returns false once CRYPT_KDF_CACHE_MISS_MAX (16) derivations have occurred, before performing the expensive derivation. SetKey30 is changed from void to bool, SetCryptKeys propagates that result, and ReadHeader15 (and the pre-existing RAR5 path) treat a false result as FailedHeaderDecryption; because header metadata is parsed before key derivation, all entries are still enumerated and still reported as encrypted.
CryptData instance at 16, degrading gracefully to “undecryptable” for the excess while preserving full metadata enumeration.Attack Path
- Craft a many-salt archive An attacker builds a RAR 4.x (or 5.x) archive containing a large number of encrypted entries (e.g. 500 zero-length files), each assigned a distinct salt.
- Deliver to a victim
The archive is downloaded by a Chrome user, triggering Safe Browsing’s
SandboxedRarAnalyzerto open and enumerate it in thefile_utilutility process. - Force per-entry derivation
For each entry, UnRAR calls
SetCryptKeys→SetKey30/SetKey50; because every salt is unique the four-slot cache always misses, so a full ~262144-round SHA-1 (or PBKDF2) derivation runs per entry. - Exhaust CPU during analysis With no cap, total work grows linearly with the attacker-chosen entry count, spending unbounded CPU time in the sandboxed analyzer on a single download (inferred: this constitutes a denial-of-service of the analysis path; the advisory characterizes it only as “improper state validation”).
Impact Assessment
file_util utility process during download analysis (inferred: resource-exhaustion / denial-of-service; the advisory classifies this as a LogicError, “improper state validation in Safebrowsing”). The only precondition is that the victim download and have Safe Browsing analyze the archive; no password or user interaction with the archive contents is required. There is no indication in the patch of memory corruption, and no exploited-in-the-wild status or severity beyond the advisory’s classification is stated; any escalation beyond degraded/blocked analysis is not demonstrated by the diff.Changed Functions
| Function | Change | Notes |
|---|---|---|
CryptDatathird_party/unrar/patches/chromium_changes.patch |
modified | |
TEST_Fchrome/services/file_util/public/cpp/sandboxed_rar_analyzer_unittest.cc |
modified |
Files Changed
chrome/services/file_util/public/cpp/sandboxed_rar_analyzer_unittest.ccthird_party/unrar/src/crypt.hppthird_party/unrar/patches/chromium_changes.patchthird_party/unrar/src/crypt3.cppthird_party/unrar/src/crypt5.cppthird_party/unrar/src/crypt.cppchrome/test/data/safe_browsing/rar/v4_many_encrypted.rarthird_party/unrar/src/arcread.cpp
Audit Directions
- Unbounded expensive-primitive loops on attacker-controlled countsLook for cryptographic KDFs, hashing, or decompression driven once per archive entry/record with no per-parse cap, where a malformed file can multiply a fixed per-item cost without bound.
- Cache-defeating attacker inputsAudit small fixed-size caches (like the four-slot
KDF3Cache/KDF5Cache) keyed on attacker-controlled fields (salts, nonces); a crafted stream of distinct keys can force worst-case recomputation on every access. - Discarded return values on validation/derivation callsSearch for functions changed to
void-return or callers ignoring success/failure (asSetCryptKeysdid forSetKey30), where an unpropagated failure lets an expensive or invalid operation proceed unchecked.
Patch
From 09845d8ce5864c30606117013d0a10859a6f5a58 Mon Sep 17 00:00:00 2001
From: Eriko Kurimoto <elkurin@google.com>
Date: Sun, 09 Aug 2026 20:06:39 -0700
Subject: [PATCH] [unrar] Bound the number of key derivations per archive
UnRAR caches at most four derived keys per CryptData instance, keyed by
(password, salt). RAR 2.9/3.x file headers and RAR 5.x encryption
records each carry a per-entry salt, so an archive that uses a fresh
salt for every entry forces a full SHA-1 or PBKDF2 key derivation per
entry, regardless of whether the supplied password is correct. For RAR
2.9/3.x the per-key cost is fixed at 262144 SHA-1 rounds.
Cap the number of distinct keys derived per CryptData instance at
CRYPT_KDF_CACHE_MISS_MAX (16). SetKey30 now returns bool, and
SetCryptKeys propagates the result, mirroring the existing SetKey50
behaviour. The ReadHeader15 RAR3 header-decryption call now checks the
result in the same way the RAR5 path already does. Header metadata is
parsed before any key derivation, so all entries are still enumerated
and reported as encrypted.
Add a SandboxedRarAnalyzerTest covering a RAR 4.x archive with 500
distinct-salt encrypted entries to exercise the new path.
Bug: 513245072
Change-Id: Ie4132eac029d32d16c615bd5b75b32c3bf4e3662
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8158623
Reviewed-by: Yaw Frempong <yawfrempong@google.com>
Reviewed-by: Daniel Rubery <drubery@chromium.org>
SLSA-Policy-Verified: SLSA Policy Verification Service <devtools-gerritcodereview-exitgate@google.com>
Commit-Queue: Eriko Kurimoto <elkurin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1676243}
---
diff --git a/third_party/unrar/patches/chromium_changes.patch b/third_party/unrar/patches/chromium_changes.patch
index 88114b1..c5e6063 100644
--- a/third_party/unrar/patches/chromium_changes.patch
+++ b/third_party/unrar/patches/chromium_changes.patch
@@ -42,6 +42,23 @@
#ifdef USE_QOPEN
bool Open(const std::wstring &Name,uint Mode=FMF_READ) override;
int Read(void *Data,size_t Size) override;
+diff --git c/third_party/unrar/src/arcread.cpp w/third_party/unrar/src/arcread.cpp
+index 7cfa30bf6f40b..204023fb52ed2 100644
+--- c/third_party/unrar/src/arcread.cpp
++++ w/third_party/unrar/src/arcread.cpp
+@@ -160,7 +160,11 @@ size_t Archive::ReadHeader15()
+ UnexpEndArcMsg();
+ return 0;
+ }
+- HeadersCrypt.SetCryptKeys(false,CRYPT_RAR30,&Cmd->Password,Salt,NULL,0,NULL,NULL);
++ if (!HeadersCrypt.SetCryptKeys(false,CRYPT_RAR30,&Cmd->Password,Salt,NULL,0,NULL,NULL))
++ {
++ FailedHeaderDecryption=true;
++ return 0;
++ }
+ Raw.SetCrypt(&HeadersCrypt);
+ #endif
+ }
diff --git c/third_party/unrar/src/blake2s.hpp w/third_party/unrar/src/blake2s.hpp
index 90b7885fde18a..4d30fe8685745 100644
--- c/third_party/unrar/src/blake2s.hpp
@@ -99,11 +116,34 @@
}
+diff --git c/third_party/unrar/src/crypt.cpp w/third_party/unrar/src/crypt.cpp
+index 9c754ffe404d8..dbc1fb0c3cef3 100644
+--- c/third_party/unrar/src/crypt.cpp
++++ w/third_party/unrar/src/crypt.cpp
+@@ -13,6 +13,9 @@ CryptData::CryptData()
+ Method=CRYPT_NONE;
+ KDF3CachePos=0;
+ KDF5CachePos=0;
++#if defined(CHROMIUM_UNRAR)
++ KDFCacheMisses=0;
++#endif
+ memset(CRCTab,0,sizeof(CRCTab));
+ }
+
+@@ -83,7 +86,7 @@ bool CryptData::SetCryptKeys(bool Encrypt,CRYPT_METHOD Method,
+ break;
+ #endif
+ case CRYPT_RAR30:
+- SetKey30(Encrypt,Password,PwdW,Salt);
++ Success=SetKey30(Encrypt,Password,PwdW,Salt);
+ break;
+ case CRYPT_RAR50:
+ Success=SetKey50(Encrypt,Password,PwdW,Salt,InitV,Lg2Cnt,HashKey,PswCheck);
diff --git c/third_party/unrar/src/crypt.hpp w/third_party/unrar/src/crypt.hpp
-index 286c84807c3d5..6d100cdfe9536 100644
+index 286c84807c3d5..cc5aa4d781a1f 100644
--- c/third_party/unrar/src/crypt.hpp
+++ w/third_party/unrar/src/crypt.hpp
-@@ -17,9 +17,16 @@ enum CRYPT_METHOD {
+@@ -17,9 +17,23 @@ enum CRYPT_METHOD {
#define CRYPT_BLOCK_MASK (CRYPT_BLOCK_SIZE-1) // 0xf
#define CRYPT5_KDF_LG2_COUNT 15 // LOG2 of PDKDF2 iteration count.
@@ -114,12 +154,82 @@
#define CRYPT5_KDF_LG2_COUNT_MAX 24 // LOG2 of maximum accepted iteration count.
+#endif
+
++#if defined(CHROMIUM_UNRAR)
++// Maximum number of distinct keys to derive per CryptData instance. Archives
++// using more salts than this will have remaining encrypted data treated as
++// undecryptable, while still allowing header metadata to be enumerated.
++#define CRYPT_KDF_CACHE_MISS_MAX 16
++#endif
++
#define CRYPT_VERSION 0 // Supported encryption version.
+static_assert(CRYPT5_KDF_LG2_COUNT <= CRYPT5_KDF_LG2_COUNT_MAX);
class CryptData
{
+@@ -79,7 +93,7 @@ class CryptData
+ void EncryptBlock20(byte *Buf);
+ void DecryptBlock20(byte *Buf);
+
+- void SetKey30(bool Encrypt,SecPassword *Password,const wchar *PwdW,const byte *Salt);
++ bool SetKey30(bool Encrypt,SecPassword *Password,const wchar *PwdW,const byte *Salt);
+ bool SetKey50(bool Encrypt,SecPassword *Password,const wchar *PwdW,const byte *Salt,const byte *InitV,uint Lg2Cnt,byte *HashKey,byte *PswCheck);
+
+ KDF3CacheItem KDF3Cache[4];
+@@ -88,6 +102,10 @@ class CryptData
+ KDF5CacheItem KDF5Cache[4];
+ uint KDF5CachePos;
+
++#if defined(CHROMIUM_UNRAR)
++ uint KDFCacheMisses;
++#endif
++
+ CRYPT_METHOD Method;
+
+ Rijndael rin;
+diff --git c/third_party/unrar/src/crypt3.cpp w/third_party/unrar/src/crypt3.cpp
+index e6e3a82cbb23a..458cc33d7acf4 100644
+--- c/third_party/unrar/src/crypt3.cpp
++++ w/third_party/unrar/src/crypt3.cpp
+@@ -1,4 +1,4 @@
+-void CryptData::SetKey30(bool Encrypt,SecPassword *Password,const wchar *PwdW,const byte *Salt)
++bool CryptData::SetKey30(bool Encrypt,SecPassword *Password,const wchar *PwdW,const byte *Salt)
+ {
+ byte AESKey[16],AESInit[16];
+
+@@ -17,6 +17,10 @@ void CryptData::SetKey30(bool Encrypt,SecPassword *Password,const wchar *PwdW,co
+
+ if (!Cached)
+ {
++#if defined(CHROMIUM_UNRAR)
++ if (KDFCacheMisses++>=CRYPT_KDF_CACHE_MISS_MAX)
++ return false;
++#endif
+ byte RawPsw[2*MAXPASSWORD+SIZE_SALT30];
+ size_t PswLength=wcslen(PwdW);
+ size_t RawLength=2*PswLength;
+@@ -65,5 +69,6 @@ void CryptData::SetKey30(bool Encrypt,SecPassword *Password,const wchar *PwdW,co
+ rin.Init(Encrypt, AESKey, 128, AESInit);
+ cleandata(AESKey,sizeof(AESKey));
+ cleandata(AESInit,sizeof(AESInit));
++ return true;
+ }
+
+diff --git c/third_party/unrar/src/crypt5.cpp w/third_party/unrar/src/crypt5.cpp
+index 2183c8f0814b5..ed2dfc966436a 100644
+--- c/third_party/unrar/src/crypt5.cpp
++++ w/third_party/unrar/src/crypt5.cpp
+@@ -155,6 +155,10 @@ bool CryptData::SetKey50(bool Encrypt,SecPassword *Password,const wchar *PwdW,
+
+ if (!Found)
+ {
++#if defined(CHROMIUM_UNRAR)
++ if (KDFCacheMisses++>=CRYPT_KDF_CACHE_MISS_MAX)
++ return false;
++#endif
+ char PwdUtf[MAXPASSWORD*4];
+ WideToUtf(PwdW,PwdUtf,ASIZE(PwdUtf));
+
diff --git c/third_party/unrar/src/errhnd.cpp w/third_party/unrar/src/errhnd.cpp
index b347678fe1903..0a363a79c74b8 100644
--- c/third_party/unrar/src/errhnd.cpp
diff --git a/third_party/unrar/src/arcread.cpp b/third_party/unrar/src/arcread.cpp
index 7cfa30bf..204023fb 100644
--- a/third_party/unrar/src/arcread.cpp
+++ b/third_party/unrar/src/arcread.cpp
@@ -160,7 +160,11 @@
UnexpEndArcMsg();
return 0;
}
- HeadersCrypt.SetCryptKeys(false,CRYPT_RAR30,&Cmd->Password,Salt,NULL,0,NULL,NULL);
+ if (!HeadersCrypt.SetCryptKeys(false,CRYPT_RAR30,&Cmd->Password,Salt,NULL,0,NULL,NULL))
+ {
+ FailedHeaderDecryption=true;
+ return 0;
+ }
Raw.SetCrypt(&HeadersCrypt);
#endif
}
diff --git a/third_party/unrar/src/crypt.cpp b/third_party/unrar/src/crypt.cpp
index 9c754ff..dbc1fb0c 100644
--- a/third_party/unrar/src/crypt.cpp
+++ b/third_party/unrar/src/crypt.cpp
@@ -13,6 +13,9 @@
Method=CRYPT_NONE;
KDF3CachePos=0;
KDF5CachePos=0;
+#if defined(CHROMIUM_UNRAR)
+ KDFCacheMisses=0;
+#endif
memset(CRCTab,0,sizeof(CRCTab));
}
@@ -83,7 +86,7 @@
break;
#endif
case CRYPT_RAR30:
- SetKey30(Encrypt,Password,PwdW,Salt);
+ Success=SetKey30(Encrypt,Password,PwdW,Salt);
break;
case CRYPT_RAR50:
Success=SetKey50(Encrypt,Password,PwdW,Salt,InitV,Lg2Cnt,HashKey,PswCheck);
diff --git a/third_party/unrar/src/crypt.hpp b/third_party/unrar/src/crypt.hpp
index 6d100cd..cc5aa4d 100644
--- a/third_party/unrar/src/crypt.hpp
+++ b/third_party/unrar/src/crypt.hpp
@@ -24,6 +24,13 @@
#define CRYPT5_KDF_LG2_COUNT_MAX 24 // LOG2 of maximum accepted iteration count.
#endif
+#if defined(CHROMIUM_UNRAR)
+// Maximum number of distinct keys to derive per CryptData instance. Archives
+// using more salts than this will have remaining encrypted data treated as
+// undecryptable, while still allowing header metadata to be enumerated.
+#define CRYPT_KDF_CACHE_MISS_MAX 16
+#endif
+
#define CRYPT_VERSION 0 // Supported encryption version.
static_assert(CRYPT5_KDF_LG2_COUNT <= CRYPT5_KDF_LG2_COUNT_MAX);
@@ -86,7 +93,7 @@
void EncryptBlock20(byte *Buf);
void DecryptBlock20(byte *Buf);
- void SetKey30(bool Encrypt,SecPassword *Password,const wchar *PwdW,const byte *Salt);
+ bool SetKey30(bool Encrypt,SecPassword *Password,const wchar *PwdW,const byte *Salt);
bool SetKey50(bool Encrypt,SecPassword *Password,const wchar *PwdW,const byte *Salt,const byte *InitV,uint Lg2Cnt,byte *HashKey,byte *PswCheck);
KDF3CacheItem KDF3Cache[4];
@@ -95,6 +102,10 @@
KDF5CacheItem KDF5Cache[4];
uint KDF5CachePos;
+#if defined(CHROMIUM_UNRAR)
+ uint KDFCacheMisses;
+#endif
+
CRYPT_METHOD Method;
Rijndael rin;
diff --git a/third_party/unrar/src/crypt3.cpp b/third_party/unrar/src/crypt3.cpp
index e6e3a82..458cc33 100644
--- a/third_party/unrar/src/crypt3.cpp
+++ b/third_party/unrar/src/crypt3.cpp
@@ -1,4 +1,4 @@
-void CryptData::SetKey30(bool Encrypt,SecPassword *Password,const wchar *PwdW,const byte *Salt)
+bool CryptData::SetKey30(bool Encrypt,SecPassword *Password,const wchar *PwdW,const byte *Salt)
{
byte AESKey[16],AESInit[16];
@@ -17,6 +17,10 @@
if (!Cached)
{
+#if defined(CHROMIUM_UNRAR)
+ if (KDFCacheMisses++>=CRYPT_KDF_CACHE_MISS_MAX)
+ return false;
+#endif
byte RawPsw[2*MAXPASSWORD+SIZE_SALT30];
size_t PswLength=wcslen(PwdW);
size_t RawLength=2*PswLength;
@@ -65,5 +69,6 @@
rin.Init(Encrypt, AESKey, 128, AESInit);
cleandata(AESKey,sizeof(AESKey));
cleandata(AESInit,sizeof(AESInit));
+ return true;
}
diff --git a/third_party/unrar/src/crypt5.cpp b/third_party/unrar/src/crypt5.cpp
index 2183c8f..ed2dfc9 100644
--- a/third_party/unrar/src/crypt5.cpp
+++ b/third_party/unrar/src/crypt5.cpp
@@ -155,6 +155,10 @@
if (!Found)
{
+#if defined(CHROMIUM_UNRAR)
+ if (KDFCacheMisses++>=CRYPT_KDF_CACHE_MISS_MAX)
+ return false;
+#endif
char PwdUtf[MAXPASSWORD*4];
WideToUtf(PwdW,PwdUtf,ASIZE(PwdUtf));
diff --git a/chrome/services/file_util/public/cpp/sandboxed_rar_analyzer_unittest.cc b/chrome/services/file_util/public/cpp/sandboxed_rar_analyzer_unittest.cc
index 15c3c5e..326683e 100644
--- a/chrome/services/file_util/public/cpp/sandboxed_rar_analyzer_unittest.cc
+++ b/chrome/services/file_util/public/cpp/sandboxed_rar_analyzer_unittest.cc
@@ -330,6 +330,22 @@
EXPECT_TRUE(results.archived_archive_filenames.empty());
}
+TEST_F(SandboxedRarAnalyzerTest, AnalyzeRar4WithManyEncryptedEntries) {
+ // v4_many_encrypted.rar is a RAR 4.x archive containing 500 zero-length
+ // encrypted entries, each with a distinct salt. Analysis should enumerate
+ // every entry without spending unbounded time on key derivation.
+ base::FilePath path;
+ ASSERT_NO_FATAL_FAILURE(path = GetFilePath("v4_many_encrypted.rar"));
+
+ safe_browsing::ArchiveAnalyzerResults results;
+ AnalyzeFile(path, &results);
+
+ ASSERT_TRUE(results.success);
+ EXPECT_TRUE(results.has_executable);
+ EXPECT_EQ(500, results.archived_binary.size());
+ EXPECT_TRUE(results.encryption_info.is_encrypted);
+}
+
TEST_F(SandboxedRarAnalyzerTest, AnalyzeRarContainingExecutable) {
// Can detect when .rar contains executable files.
// has_exe.rar contains 1 file: signed.exe
diff --git a/chrome/test/data/safe_browsing/rar/v4_many_encrypted.rar b/chrome/test/data/safe_browsing/rar/v4_many_encrypted.rar
new file mode 100644
index 0000000..bfdde134
--- /dev/null
+++ b/chrome/test/data/safe_browsing/rar/v4_many_encrypted.rar
Binary files differ
Regression Test / PoC
diff --git a/chrome/services/file_util/public/cpp/sandboxed_rar_analyzer_unittest.cc b/chrome/services/file_util/public/cpp/sandboxed_rar_analyzer_unittest.cc
index 15c3c5e..326683e 100644
--- a/chrome/services/file_util/public/cpp/sandboxed_rar_analyzer_unittest.cc
+++ b/chrome/services/file_util/public/cpp/sandboxed_rar_analyzer_unittest.cc
@@ -330,6 +330,22 @@
EXPECT_TRUE(results.archived_archive_filenames.empty());
}
+TEST_F(SandboxedRarAnalyzerTest, AnalyzeRar4WithManyEncryptedEntries) {
+ // v4_many_encrypted.rar is a RAR 4.x archive containing 500 zero-length
+ // encrypted entries, each with a distinct salt. Analysis should enumerate
+ // every entry without spending unbounded time on key derivation.
+ base::FilePath path;
+ ASSERT_NO_FATAL_FAILURE(path = GetFilePath("v4_many_encrypted.rar"));
+
+ safe_browsing::ArchiveAnalyzerResults results;
+ AnalyzeFile(path, &results);
+
+ ASSERT_TRUE(results.success);
+ EXPECT_TRUE(results.has_executable);
+ EXPECT_EQ(500, results.archived_binary.size());
+ EXPECT_TRUE(results.encryption_info.is_encrypted);
+}
+
TEST_F(SandboxedRarAnalyzerTest, AnalyzeRarContainingExecutable) {
// Can detect when .rar contains executable files.
// has_exe.rar contains 1 file: signed.exe
diff --git a/chrome/test/data/safe_browsing/rar/v4_many_encrypted.rar b/chrome/test/data/safe_browsing/rar/v4_many_encrypted.rar
new file mode 100644
index 0000000..bfdde134
--- /dev/null
+++ b/chrome/test/data/safe_browsing/rar/v4_many_encrypted.rar
Binary files differ