diff --git a/chrome/browser/enterprise/profile_management/oidc_auth_response_capture_navigation_throttle.cc b/chrome/browser/enterprise/profile_management/oidc_auth_response_capture_navigation_throttle.cc index ef58d81592120..db1c39d0258f8 100644 --- a/chrome/browser/enterprise/profile_management/oidc_auth_response_capture_navigation_throttle.cc +++ b/chrome/browser/enterprise/profile_management/oidc_auth_response_capture_navigation_throttle.cc @@ -264,6 +264,16 @@ OidcAuthResponseCaptureNavigationThrottle::AttemptToTriggerUrlInterception() { for (const auto& chain_url : navigation_handle()->GetRedirectChain()) { if (!GetOidcEnrollmentUrlMatcher()->MatchURL(chain_url).empty()) { + // [POC INSTRUMENTATION] Prove subdomain-wildcard / any-hop bypass. + // The matcher was built from kEntraMcasHost="https://mcas.ms" via + // AddAllowFiltersWithLimit -> match_subdomains=true, so ANY *.mcas.ms + // host satisfies this check. The loop also accepts ANY hop in the + // chain, not just the immediate predecessor of the enrollment URL. + LOG(ERROR) << "[POC] REDIRECT-CHAIN BYPASS: trusted-host matcher " + "accepted chain URL '" + << chain_url.spec() + << "' (host=" << chain_url.host() + << ") -- subdomain wildcard / any-hop check passed"; accept_redirect = true; break; } @@ -346,6 +356,16 @@ OidcAuthResponseCaptureNavigationThrottle::AttemptToTriggerUrlInterception() { return CANCEL_AND_IGNORE; } + // [POC INSTRUMENTATION] CWE-347: prove that jwt_sections[2] (the signature) + // is never cryptographically verified. Only jwt_sections[1] (payload) is + // base64-decoded above. The signature could be ANY string and this code + // path proceeds. An attacker can forge arbitrary iss/sub claims. + LOG(ERROR) << "[POC] CWE-347 UNSIGNED-JWT ACCEPTED: signature section = '" + << jwt_sections[2] + << "' (len=" << jwt_sections[2].size() + << ") -- this signature was NEVER cryptographically verified"; + LOG(ERROR) << "[POC] decoded payload from unsigned JWT: " << json_payload; + interception_triggered_ = true; data_decoder::DataDecoder::ParseJsonIsolated( json_payload, @@ -401,6 +421,17 @@ void OidcAuthResponseCaptureNavigationThrottle::RegisterWithOidcTokens( RecordOidcInterceptionFunnelStep( OidcInterceptionFunnelStep::kSuccessfulInfoParsed); + // [POC INSTRUMENTATION] Prove attacker-controlled identity from the + // unsigned JWT is forwarded into the enrollment interceptor. These values + // become unique_user_identifier_ in oidc_authentication_signin_interceptor.cc + // and the trusted browser-chrome enrollment dialog is shown for them. + LOG(ERROR) << "[POC] *** VULNERABILITY CONFIRMED *** " + "MaybeInterceptOidcAuthentication invoked with " + "ATTACKER-CONTROLLED identity from UNSIGNED JWT: " + "iss='" << *issuer_id << "' sub='" << *subject_id + << "' -- next step is ShowOidcInterceptionDialog " + "(trusted browser-native modal)"; + interceptor->MaybeInterceptOidcAuthentication( navigation_handle()->GetWebContents(), tokens, *issuer_id, *subject_id, std::string(), diff --git a/chrome/browser/enterprise/signin/oidc_authentication_signin_interceptor.cc b/chrome/browser/enterprise/signin/oidc_authentication_signin_interceptor.cc index 5f8b3f299cef2..11d9b4f55a589 100644 --- a/chrome/browser/enterprise/signin/oidc_authentication_signin_interceptor.cc +++ b/chrome/browser/enterprise/signin/oidc_authentication_signin_interceptor.cc @@ -135,6 +135,13 @@ void OidcAuthenticationSigninInterceptor::MaybeInterceptOidcAuthentication( unique_user_identifier_ = base::StringPrintf( kUniqueIdentifierTemplate, issuer_id.c_str(), subject_id.c_str()); + // [POC INSTRUMENTATION] The unique_user_identifier_ used for profile + // matching and creation is built directly from the unsigned JWT's iss/sub. + LOG(ERROR) << "[POC] OidcAuthenticationSigninInterceptor: " + "unique_user_identifier_ set to '" + << unique_user_identifier_ + << "' (built from unverified iss/sub) -- proceeding to dialog"; + CHECK(!switch_to_entry_); base::FilePath profile_path = profile_->GetPath(); for (const auto* entry : g_browser_process->profile_manager() @@ -201,6 +208,18 @@ void OidcAuthenticationSigninInterceptor::MaybeInterceptOidcAuthentication( return; } + // [POC INSTRUMENTATION] We are about to call ShowOidcInterceptionDialog -> + // DiceWebSigninInterceptorDelegate::ShowOidcInterceptionDialog -> + // ShowModalManagedUserNoticeDialog with profile_creation_required_by_policy + // = true. This is the trusted browser-chrome modal. No DM-server round-trip + // has happened yet -- the dialog appears BEFORE any server-side validation + // of the (unsigned, forged) tokens. + LOG(ERROR) << "[POC] *** TRUSTED DIALOG TRIGGERED *** " + "delegate_->ShowOidcInterceptionDialog about to be called " + "for forged identity '" << unique_user_identifier_ + << "' -- on a desktop with display, the user now sees a " + "browser-native 'Your organization requires a profile' modal"; + // TODO(374765466): Simplify this logic by creating a function that can call // one of the following callbacks according to an argument, then passing // this single callback instead of three separate callbacks.