Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in FileAPI
DescriptionIncorrect authorization in FileAPI
ComponentFileAPI
Bug ClassLogic Error
Tracker497093426
Fix commit8f922b345d4e (chromium/src) +145/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
GetAccessCallback
content/browser/blob_storage/file_backed_blob_factory_base.cc
modified
if
content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
modified

Files Changed

  • content/browser/blob_storage/file_backed_blob_factory_base.cc
  • content/browser/blob_storage/file_backed_blob_factory_frame_impl.cc
  • content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
  • content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
From 8f922b345d4e97005fb8825aaf0f9af76ebc81c3 Mon Sep 17 00:00:00 2001
From: Eriko Kurimoto <[email protected]>
Date: Thu, 06 Aug 2026 00:18:34 -0700
Subject: [PATCH] Deny file-backed blob access when destination URL is unknown

GetAccessCallback() in FileBackedBlobFactoryBase returned a null
callback when the destination URL was invalid, even though a
ScopedFileAccessDelegate was installed. Downstream consumers treat a
null callback as "no destination known" and route to the default access
path, which is not the intended behaviour when a delegate is performing
per-destination checks.

Separate the two early-return conditions: keep returning a null callback
when no delegate is installed, but return a callback that denies access
when a delegate is installed and the destination URL is invalid (e.g.
opaque-origin workers, fenced frames). Update the comment in
FileBackedBlobFactoryFrameImpl::GetCurrentUrl() accordingly.

TAG=agy
CONV=c4887307-6afe-4a3c-a864-ba0143515e6a

Bug: 497093426
Change-Id: I5ee02e8788e35e4619b94c16b64a6d923a3dacd7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8182087
Commit-Queue: Eriko Kurimoto <[email protected]>
Reviewed-by: Mingyu Lei <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1674764}
---

diff --git a/content/browser/blob_storage/file_backed_blob_factory_base.cc b/content/browser/blob_storage/file_backed_blob_factory_base.cc
index 0065e6f..84f37d04 100644
--- a/content/browser/blob_storage/file_backed_blob_factory_base.cc
+++ b/content/browser/blob_storage/file_backed_blob_factory_base.cc
@@ -4,10 +4,12 @@
 
 #include "content/browser/blob_storage/file_backed_blob_factory_base.h"
 
+#include "base/functional/bind.h"
 #include "base/functional/callback_helpers.h"
 #include "base/process/process_handle.h"
 #include "base/task/bind_post_task.h"
 #include "base/task/sequenced_task_runner.h"
+#include "components/file_access/scoped_file_access.h"
 #include "content/browser/blob_storage/chrome_blob_storage_context.h"
 #include "content/browser/security/cpsp/child_process_security_policy_impl.h"
 #include "content/public/browser/browser_context.h"
@@ -26,11 +28,22 @@
 
 file_access::ScopedFileAccessDelegate::RequestFilesAccessIOCallback
 GetAccessCallback(const GURL& url_for_file_access_checks) {
-  if (!file_access::ScopedFileAccessDelegate::HasInstance() ||
-      !url_for_file_access_checks.is_valid()) {
+  if (!file_access::ScopedFileAccessDelegate::HasInstance()) {
     return base::NullCallback();
   }
 
+  // When a delegate is installed, per-destination access checks are required.
+  // If the destination URL cannot be determined or is invalid, explicitly
+  // deny access rather than returning a null callback which would fall back
+  // to default access handling.
+  if (!url_for_file_access_checks.is_valid()) {
+    return base::BindRepeating(
+        [](const std::vector<base::FilePath>&,
+           base::OnceCallback<void(file_access::ScopedFileAccess)> callback) {
+          std::move(callback).Run(file_access::ScopedFileAccess::Denied());
+        });
+  }
+
   file_access::ScopedFileAccessDelegate* file_access =
       file_access::ScopedFileAccessDelegate::Get();
   CHECK(file_access);
diff --git a/content/browser/blob_storage/file_backed_blob_factory_frame_impl.cc b/content/browser/blob_storage/file_backed_blob_factory_frame_impl.cc
index 6991a4f..e9f4afe 100644
--- a/content/browser/blob_storage/file_backed_blob_factory_frame_impl.cc
+++ b/content/browser/blob_storage/file_backed_blob_factory_frame_impl.cc
@@ -28,8 +28,8 @@
 
 GURL FileBackedBlobFactoryFrameImpl::GetCurrentUrl() {
   // TODO(b/276857839): handling of fenced frames is still in discussion. For
-  // now we use an invalid GURL as destination URL. This will allow access to
-  // unrestricted files but block access to restricted ones.
+  // now we use an invalid GURL as destination URL, which causes file access to
+  // be denied when a ScopedFileAccessDelegate is installed.
   if (render_frame_host().IsNestedWithinFencedFrame()) {
     return GURL();
   }
diff --git a/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc b/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
index fdeeadba..f682cc97 100644
--- a/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
+++ b/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
@@ -5,6 +5,8 @@
 #include "content/browser/blob_storage/file_backed_blob_factory_frame_impl.h"
 
 #include "base/test/task_environment.h"
+#include "base/test/test_future.h"
+#include "components/file_access/scoped_file_access.h"
 #include "components/file_access/test/mock_scoped_file_access_delegate.h"
 #include "content/browser/blob_storage/chrome_blob_storage_context.h"
 #include "content/browser/security/cpsp/child_process_security_policy_impl.h"
@@ -16,6 +18,8 @@
 #include "mojo/public/cpp/system/functions.h"
 #include "storage/browser/blob/blob_data_builder.h"
 #include "storage/browser/blob/blob_data_handle.h"
+#include "storage/browser/blob/blob_data_item.h"
+#include "storage/browser/blob/blob_data_snapshot.h"
 #include "storage/browser/blob/blob_storage_constants.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/mojom/blob/data_element.mojom.h"
@@ -302,4 +306,66 @@
   EXPECT_NE(GURL(kMainFrameUrl), captured_destination);
 }
 
+TEST_F(FileBackedBlobFactoryFrameImplTest,
+       Register_FencedFrameWithScopedFileAccessDelegate) {
+  main_test_rfh()->InitializeRenderFrameIfNeeded();
+  TestRenderFrameHost* fenced_rfh = main_test_rfh()->AppendFencedFrame();
+  EXPECT_TRUE(fenced_rfh);
+  if (!fenced_rfh) {
+    return;
+  }
+  fenced_rfh->SetLastCommittedUrl(GURL(kSubframeUrl));
+
+  mojo::AssociatedRemote<blink::mojom::FileBackedBlobFactory> fenced_factory;
+  FileBackedBlobFactoryFrameImpl::CreateForCurrentDocument(
+      fenced_rfh, fenced_factory.BindNewEndpointAndPassDedicatedReceiver());
+
+  file_access::MockScopedFileAccessDelegate scoped_file_access_delegate;
+  EXPECT_CALL(scoped_file_access_delegate, CreateFileAccessCallback).Times(0);
+
+  const base::FilePath path = base::FilePath(TEST_PATH("/dir/testfile"));
+  ChildProcessSecurityPolicyImpl::GetInstance()->GrantReadFile(
+      fenced_rfh->GetProcess()->GetID(), path);
+  EXPECT_TRUE(ChildProcessSecurityPolicyImpl::GetInstance()->CanReadFile(
+      fenced_rfh->GetProcess()->GetID(), path));
+
+  auto element =
+      blink::mojom::DataElementFile::New(path, kOffset, kSize, std::nullopt);
+
+  mojo::Remote<blink::mojom::Blob> blob;
+  fenced_factory->RegisterBlob(blob.BindNewPipeAndPassReceiver(), kId, kType,
+                               std::move(element));
+  fenced_factory.FlushForTesting();
+  blob.FlushForTesting();
+
+  EXPECT_TRUE(bad_messages_.empty());
+
+  auto* blob_storage_context =
+      ChromeBlobStorageContext::GetFor(main_test_rfh()->GetBrowserContext())
+          ->context();
+
+  std::unique_ptr<storage::BlobDataHandle> handle =
+      blob_storage_context->GetBlobDataFromUUID(kId);
+  WaitForBlobCompletion(handle.get());
+
+  EXPECT_FALSE(handle->IsBroken());
+  EXPECT_EQ(storage::BlobStatus::DONE, handle->GetBlobStatus());
+
+  std::unique_ptr<storage::BlobDataSnapshot> snapshot =
+      handle->CreateSnapshot();
+  EXPECT_EQ(1u, snapshot->items().size());
+  if (snapshot->items().size() != 1u) {
+    return;
+  }
+  auto file_access = snapshot->items()[0]->file_access();
+  EXPECT_FALSE(file_access.is_null());
+  if (file_access.is_null()) {
+    return;
+  }
+
+  base::test::TestFuture<file_access::ScopedFileAccess> future;
+  file_access.Run({path}, future.GetCallback());
+  EXPECT_FALSE(future.Take().is_allowed());
+}
+
 }  // namespace content
diff --git a/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc b/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
index cef10733..9f89b33b 100644
--- a/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
+++ b/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
@@ -7,6 +7,8 @@
 #include <memory>
 
 #include "base/functional/callback_helpers.h"
+#include "base/test/test_future.h"
+#include "components/file_access/scoped_file_access.h"
 #include "components/file_access/test/mock_scoped_file_access_delegate.h"
 #include "content/browser/blob_storage/chrome_blob_storage_context.h"
 #include "content/browser/security/cpsp/child_process_security_policy_impl.h"
@@ -18,6 +20,8 @@
 #include "mojo/public/cpp/system/functions.h"
 #include "storage/browser/blob/blob_data_builder.h"
 #include "storage/browser/blob/blob_data_handle.h"
+#include "storage/browser/blob/blob_data_item.h"
+#include "storage/browser/blob/blob_data_snapshot.h"
 #include "storage/browser/blob/blob_storage_constants.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/mojom/blob/data_element.mojom.h"
@@ -257,6 +261,64 @@
   EXPECT_EQ(expected_blob_data, *handle->CreateSnapshot());
 }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc b/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
index fdeeadba..f682cc97 100644
--- a/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
+++ b/content/browser/blob_storage/file_backed_blob_factory_frame_impl_unittest.cc
@@ -5,6 +5,8 @@
 #include "content/browser/blob_storage/file_backed_blob_factory_frame_impl.h"
 
 #include "base/test/task_environment.h"
+#include "base/test/test_future.h"
+#include "components/file_access/scoped_file_access.h"
 #include "components/file_access/test/mock_scoped_file_access_delegate.h"
 #include "content/browser/blob_storage/chrome_blob_storage_context.h"
 #include "content/browser/security/cpsp/child_process_security_policy_impl.h"
@@ -16,6 +18,8 @@
 #include "mojo/public/cpp/system/functions.h"
 #include "storage/browser/blob/blob_data_builder.h"
 #include "storage/browser/blob/blob_data_handle.h"
+#include "storage/browser/blob/blob_data_item.h"
+#include "storage/browser/blob/blob_data_snapshot.h"
 #include "storage/browser/blob/blob_storage_constants.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/mojom/blob/data_element.mojom.h"
@@ -302,4 +306,66 @@
   EXPECT_NE(GURL(kMainFrameUrl), captured_destination);
 }
 
+TEST_F(FileBackedBlobFactoryFrameImplTest,
+       Register_FencedFrameWithScopedFileAccessDelegate) {
+  main_test_rfh()->InitializeRenderFrameIfNeeded();
+  TestRenderFrameHost* fenced_rfh = main_test_rfh()->AppendFencedFrame();
+  EXPECT_TRUE(fenced_rfh);
+  if (!fenced_rfh) {
+    return;
+  }
+  fenced_rfh->SetLastCommittedUrl(GURL(kSubframeUrl));
+
+  mojo::AssociatedRemote<blink::mojom::FileBackedBlobFactory> fenced_factory;
+  FileBackedBlobFactoryFrameImpl::CreateForCurrentDocument(
+      fenced_rfh, fenced_factory.BindNewEndpointAndPassDedicatedReceiver());
+
+  file_access::MockScopedFileAccessDelegate scoped_file_access_delegate;
+  EXPECT_CALL(scoped_file_access_delegate, CreateFileAccessCallback).Times(0);
+
+  const base::FilePath path = base::FilePath(TEST_PATH("/dir/testfile"));
+  ChildProcessSecurityPolicyImpl::GetInstance()->GrantReadFile(
+      fenced_rfh->GetProcess()->GetID(), path);
+  EXPECT_TRUE(ChildProcessSecurityPolicyImpl::GetInstance()->CanReadFile(
+      fenced_rfh->GetProcess()->GetID(), path));
+
+  auto element =
+      blink::mojom::DataElementFile::New(path, kOffset, kSize, std::nullopt);
+
+  mojo::Remote<blink::mojom::Blob> blob;
+  fenced_factory->RegisterBlob(blob.BindNewPipeAndPassReceiver(), kId, kType,
+                               std::move(element));
+  fenced_factory.FlushForTesting();
+  blob.FlushForTesting();
+
+  EXPECT_TRUE(bad_messages_.empty());
+
+  auto* blob_storage_context =
+      ChromeBlobStorageContext::GetFor(main_test_rfh()->GetBrowserContext())
+          ->context();
+
+  std::unique_ptr<storage::BlobDataHandle> handle =
+      blob_storage_context->GetBlobDataFromUUID(kId);
+  WaitForBlobCompletion(handle.get());
+
+  EXPECT_FALSE(handle->IsBroken());
+  EXPECT_EQ(storage::BlobStatus::DONE, handle->GetBlobStatus());
+
+  std::unique_ptr<storage::BlobDataSnapshot> snapshot =
+      handle->CreateSnapshot();
+  EXPECT_EQ(1u, snapshot->items().size());
+  if (snapshot->items().size() != 1u) {
+    return;
+  }
+  auto file_access = snapshot->items()[0]->file_access();
+  EXPECT_FALSE(file_access.is_null());
+  if (file_access.is_null()) {
+    return;
+  }
+
+  base::test::TestFuture<file_access::ScopedFileAccess> future;
+  file_access.Run({path}, future.GetCallback());
+  EXPECT_FALSE(future.Take().is_allowed());
+}
+
 }  // namespace content
diff --git a/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc b/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
index cef10733..9f89b33b 100644
--- a/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
+++ b/content/browser/blob_storage/file_backed_blob_factory_worker_impl_unittest.cc
@@ -7,6 +7,8 @@
 #include <memory>
 
 #include "base/functional/callback_helpers.h"
+#include "base/test/test_future.h"
+#include "components/file_access/scoped_file_access.h"
 #include "components/file_access/test/mock_scoped_file_access_delegate.h"
 #include "content/browser/blob_storage/chrome_blob_storage_context.h"
 #include "content/browser/security/cpsp/child_process_security_policy_impl.h"
@@ -18,6 +20,8 @@
 #include "mojo/public/cpp/system/functions.h"
 #include "storage/browser/blob/blob_data_builder.h"
 #include "storage/browser/blob/blob_data_handle.h"
+#include "storage/browser/blob/blob_data_item.h"
+#include "storage/browser/blob/blob_data_snapshot.h"
 #include "storage/browser/blob/blob_storage_constants.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/mojom/blob/data_element.mojom.h"
@@ -257,6 +261,64 @@
   EXPECT_EQ(expected_blob_data, *handle->CreateSnapshot());
 }
 
+TEST_F(FileBackedBlobFactoryWorkerImplTest,
+       Register_InvalidUrlWithScopedFileAccessDelegate) {
+  file_access::MockScopedFileAccessDelegate scoped_file_access_delegate;
+  EXPECT_CALL(scoped_file_access_delegate, CreateFileAccessCallback).Times(0);
+
+  // Model a worker bound for an opaque origin: the URL passed to
+  // BindReceiver() is invalid.
+  mojo::Remote<blink::mojom::FileBackedBlobFactory> factory;
+  factory_impl_->BindReceiver(factory.BindNewPipeAndPassReceiver(), GURL());
+
+  const base::FilePath path = base::FilePath(TEST_PATH("/dir/testfile"));
+
+  ChildProcessSecurityPolicyImpl::GetInstance()->GrantReadFile(process_id_,
+                                                               path);
+  EXPECT_TRUE(ChildProcessSecurityPolicyImpl::GetInstance()->CanReadFile(
+      process_id_, path));
+
+  auto element =
+      blink::mojom::DataElementFile::New(path, kOffset, kSize, std::nullopt);
+
+  mojo::Remote<blink::mojom::Blob> blob;
+  factory->RegisterBlob(blob.BindNewPipeAndPassReceiver(), kId, kType,
+                        std::move(element));
+  factory.FlushForTesting();
+  blob.FlushForTesting();
+
+  EXPECT_TRUE(bad_messages_.empty());
+
+  auto* blob_storage_context =
+      ChromeBlobStorageContext::GetFor(&context_)->context();
+
+  std::unique_ptr<storage::BlobDataHandle> handle =
+      blob_storage_context->GetBlobDataFromUUID(kId);
+  WaitForBlobCompletion(handle.get());
+
+  EXPECT_FALSE(handle->IsBroken());
+  EXPECT_EQ(storage::BlobStatus::DONE, handle->GetBlobStatus());
+
+  // Because the destination URL is unknown, the registered file item must
+  // carry an explicit access callback that denies access rather than leaving
+  // the decision to the default access path.
+  std::unique_ptr<storage::BlobDataSnapshot> snapshot =
+      handle->CreateSnapshot();
+  EXPECT_EQ(1u, snapshot->items().size());
+  if (snapshot->items().size() != 1u) {
+    return;
+  }
+  auto file_access = snapshot->items()[0]->file_access();
+  EXPECT_FALSE(file_access.is_null());
+  if (file_access.is_null()) {
+    return;
+  }
+
+  base::test::TestFuture<file_access::ScopedFileAccess> future;
+  file_access.Run({path}, future.GetCallback());
+  EXPECT_FALSE(future.Take().is_allowed());
+}
+
 TEST_F(FileBackedBlobFactoryWorkerImplTest, MultipleBindings) {
   file_access::MockScopedFileAccessDelegate scoped_file_access_delegate;
   EXPECT_CALL(scoped_file_access_delegate,
Loading diff…

Original Bug Report

reported by [email protected]

Potential DLP bypass via fail-open invalid URLs in FileBackedBlobFactory

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A logic error in FileBackedBlobFactoryBase causes Enterprise Data Loss Prevention (DLP) checks to fail-open when an invalid URL is encountered, such as in Fenced Frames. This allows an untrusted origin to bypass DLP policies and access restricted local files by escalating the access request to the SYSTEM component tier.

Affected files:

  • content/browser/blob_storage/file_backed_blob_factory_base.cc
  • storage/browser/file_system/local_file_stream_reader.cc
  • chrome/browser/chromeos/policy/dlp/dlp_scoped_file_access_delegate.cc
  • content/browser/blob_storage/file_backed_blob_factory_frame_impl.cc

Estimated timestamp from git blame: 2023-11-09

Summary

A potential vulnerability exists in the ChromeOS Data Loss Prevention (DLP) enforcement mechanism that could allow exfiltration of restricted files to untrusted web destinations.

When a file-backed blob is created from a context with an invalid or empty URL (specifically, a Fenced Frame), the system fails to apply restrictive policies. Instead, it defaults to a highly-privileged access level (DlpComponent::SYSTEM), bypassing Enterprise DLP rules.

Technical Details

The vulnerability stems from how the blob storage system interacts with the DLP delegate when an origin lacks a valid URL:

  1. Invalid URL Handling in Fenced Frames: When a file-backed blob is registered via content::FileBackedBlobFactoryBase::RegisterBlobSync, it determines the destination URL for DLP checks by calling GetCurrentUrl(). For Fenced Frames, FileBackedBlobFactoryFrameImpl::GetCurrentUrl() explicitly returns an empty, invalid GURL() (see content/browser/blob_storage/file_backed_blob_factory_frame_impl.cc:34).
  2. Null Callback Creation: RegisterBlobSync passes this empty URL to GetAccessCallback(). Because the URL is invalid (!url_for_file_access_checks.is_valid()), the helper short-circuits and returns base::NullCallback() (see content/browser/blob_storage/file_backed_blob_factory_base.cc:31). This null callback is permanently attached to the blob’s file slice.
  3. Fail-Open during Read: When the renderer attempts to read the file (e.g., via FileReader), LocalFileStreamReader::Open is invoked. It checks for a specific file_access_ callback. Finding it null, it falls back to the default access path: file_access::ScopedFileAccessDelegate::RequestDefaultFilesAccessIO (see storage/browser/file_system/local_file_stream_reader.cc:130).
  4. Privilege Escalation to SYSTEM: The default access path routes to DlpScopedFileAccessDelegate::RequestDefaultFilesAccess. In the default ChromeOS configuration, the strict enforcement flag kDataControlsFileAccessDefaultDeny is disabled. Consequently, the logic falls through to RequestFilesAccessForSystem, which sets the destination component to dlp::DlpComponent::SYSTEM (see chrome/browser/chromeos/policy/dlp/dlp_scoped_file_access_delegate.cc:167).
  5. Policy Bypass: The ChromeOS DLP daemon grants access for SYSTEM destinations, allowing the untrusted Fenced Frame to read and exfiltrate admin-restricted files.

Note: Opaque-origin workers also return an empty URL in this context, but it is currently unclear if they possess the necessary web APIs (like <input type="file">) to trigger the initial RegisterBlob call for a local file path.

Potential Attack Steps

(Note: These are suggested steps; our tooling agent does not run code to verify them.)

  1. An attacker embeds a <fencedframe> on their malicious website.
  2. Inside the Fenced Frame, the attacker presents a file picker (<input type="file">) to the user.
  3. The user is tricked into selecting an Enterprise DLP-protected file from their local ChromeOS file system.
  4. The browser registers the file-backed blob, but due to the Fenced Frame context, it assigns a null DLP access callback.
  5. The attacker’s JavaScript inside the Fenced Frame reads the file contents (e.g., using File.text()). The read succeeds because the null callback forces a fallback to SYSTEM level access, bypassing the DLP block.
  6. The attacker exfiltrates the sensitive file data.

Recommendation

GetAccessCallback in content/browser/blob_storage/file_backed_blob_factory_base.cc should be modified. Instead of returning base::NullCallback() when a valid destination URL cannot be determined, it should return a callback that explicitly denies access or applies the most restrictive default policy.

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker