Stealth Chromium Development from Scratch Part 4: Your First Patch

In this series, I demystify Chromium development by explaining everything step by step.

person Camille Louédoc-Eyries
|
calendar_today February 21, 2026
|
schedule 5 min read

In this blog series, I will explain how to develop your own Chromium fork with fingerprint mimicry.

Our goal is to be able to edit basic fingerprints, like the user-agent, the CPU core counts, but also more complicated entropy sources such as canvas fingerprinting.


So, you’ve now downloaded and compiled Chromium for the first time.

Now, we are going to focus on creating a first patch — nothing fancy; just something that will allow you to spoof your user-agent by passing a special environment variable while starting Chromium.

Where is “native code” stored?

If you’ve ever worked on a web development project, you probably accidentally asked for the definition of a native function.

For example, if you type document.querySelector, Chromium returns the following function definition:

ƒ querySelector() { [native code] }

Hm… mysterious. Can we see this native code?

In the same way, accessing navigator.userAgent triggers a C++ getter inside the Chromium codebase that generates and returns the result you are looking for. userAgent is not actually a variable that you can modify.

It’s C++ all the way down.

Finding the source code of a JS function: document.querySelector

I think that it’s better to let the Chrome docs explain what IDL files are:

​Web IDL is a language that defines how Blink interfaces are bound to V8. You need to write IDL files (e.g. xml_http_request.idl, element.idl, etc) to expose Blink interfaces to those external languages. When Blink is built, the IDL files are parsed, and the code to bind Blink implementations to V8 interfaces automatically generated.

IDL is a language that defines bindings, which allow exposing functionality from C++ into the JavaScript environment.

Let’s hunt for the IDL file that defines document.querySelector:

Terminal window
rg -l "querySelector" -g "*.idl" ./src/chromium/src

We get the following list of files:

./src/chromium/src/third_party/blink/renderer/core/dom/parent_node.idl
./src/chromium/src/third_party/blink/web_tests/external/wpt/interfaces/dom.idl

We can skip the web_tests one.

Here is the content of parent_node.idl:

parent_node.idl
// https://dom.spec.whatwg.org/#interface-parentnode
interface mixin ParentNode {
[SameObject, PerWorldBindings] readonly attribute HTMLCollection children;
[PerWorldBindings] readonly attribute Element? firstElementChild;
[PerWorldBindings] readonly attribute Element? lastElementChild;
readonly attribute unsigned long childElementCount;
[Unscopable, RaisesException, CEReactions] undefined prepend((Node or DOMString or TrustedScript)... nodes);
[Unscopable, RaisesException, CEReactions] undefined append((Node or DOMString or TrustedScript)... nodes);
[Unscopable, RaisesException, CEReactions] undefined replaceChildren((Node or DOMString or TrustedScript)... nodes);
[CEReactions, PerWorldBindings, RaisesException, MeasureAs="WebDXFeature::kMoveBefore"] undefined moveBefore(Node node, Node? child);
[Affects=Nothing, RaisesException] Element? querySelector(DOMString selectors);
[Affects=Nothing, NewObject, RaisesException] NodeList querySelectorAll(DOMString selectors);
[RuntimeEnabled=DocumentPatching, CallWith=ScriptState, RaisesException] WritableStream patchSelf();
[RuntimeEnabled=DocumentPatching, CallWith=ScriptState, RaisesException] WritableStream patchBetween(Node prev_child, Node next_child);
[RuntimeEnabled=DocumentPatching, CallWith=ScriptState, RaisesException] WritableStream patchAfter(Node ref);
[RuntimeEnabled=DocumentPatching, CallWith=ScriptState, RaisesException] WritableStream patchBefore(Node ref);
[RuntimeEnabled=DocumentPatching, CallWith=ScriptState] WritableStream patchAll();
};

Hm… no C++ code.

What if we look for references to that file?

Well, if we look closely in the docs, we can read this:

The Web IDL spec treats the Web API as a single API, spread across various IDL fragments. In practice these fragments are .idl files, stored in the codebase alongside their implementation, with basename equal to the interface name.

Thus for example the fragment defining the Node interface is written in node.idl, which is stored in the third_party/blink/renderer/core/dom directory, and is accompanied by node.h and node.cc in the same directory.

In some cases the implementation has a different name, in which case there must be an [ImplementedAs=…] extended attribute in the IDL file, and the .h/.cc files have basename equal to the value of the [ImplementedAs=…].

We cannot find a parent_node.cc file, though.

However, we can find that other idl files include our ParentNode mixin.

Terminal window
cami@onigiri ~/c/r/s/c/s/t/b/r/c/dom> rg "includes ParentNode"
document_fragment.idl
31:DocumentFragment includes ParentNode;
element.idl
192:Element includes ParentNode;
document.idl
244:Document includes ParentNode;

Remember? We were interested in document.querySelector in particular.

Is there a .cc file for Document? Yes, there is!

… but no implementation of querySelector can be found there.

However, we notice in document.h that Document inherits from ContainerNode:

document.h
class CORE_EXPORT Document : public ContainerNode,
public TreeScope,
public UseCounter,
public WidgetCreationObserver {
DEFINE_WRAPPERTYPEINFO();
// ... snip ...
}

And when we read container_node.cc, we find this:

container_node.cc
Element* ContainerNode::querySelector(const AtomicString& selectors,
ExceptionState& exception_state) {
return QuerySelector(selectors, exception_state);
}
Element* ContainerNode::QuerySelector(const AtomicString& selectors,
ExceptionState& exception_state) {
SelectorQuery* selector_query = GetDocument().GetSelectorQueryCache().Add(
selectors, GetDocument(), exception_state);
if (!selector_query)
return nullptr;
return selector_query->QueryFirst(*this);
}

Now we’re talking!

So, to recap:

  1. .idl files contain the blueprint for the JS environment in Chrome
  2. These are implemented by .cc files directly next to them
  3. But there are a bunch of includes and class inheritance that sometimes make it a bit tricky to find the exact code we’re interested in.

You cannot just “follow references” from an IDL file; you’ve got to do a little bit of detective work.

Well. You could just stay in src/third_party/blink/renderer/core/dom and use ripgrep to find the function definition directly:

Terminal window
cami@onigiri ~/c/r/s/c/s/t/b/r/c/dom> rg "querySelector\("
README.md
158:const b = document.querySelector('#B');
217:[`TreeOrderedMap`](./tree_ordered_map.h), so that `querySelector('#foo')` can
219:`root.querySelector('#foo')` can be slow if it is used in a node tree whose
document_test.cc
482: "document.querySelector('input').reportValidity(); };");
container_node.h
100: Element* querySelector(const AtomicString& selectors, ExceptionState&);
container_node.cc
1510:Element* ContainerNode::querySelector(const AtomicString& selectors,
parent_node.idl
43: [Affects=Nothing, RaisesException] Element? querySelector(DOMString selectors);
element.h
2537: // doesn't exist, and used to accelerate querySelector() (can quickly

You can quickly eliminate candidates:

  • README.md: obviously not
  • document_test.cc: testing code, and besides, it’s JavaScript
  • parent_node.idl: that’s the IDL file, not the actual code
  • element.h: this is part of a comment

The rest (container_node.h and container_node.cc) correspond to the actual code we’re interested in.

Writing a patch for navigator.userAgent

Alright. Now that we know how to find the source code of a JS attribute/function in Chromium, let’s actually patch navigator.userAgent.

Finding where navigator.userAgent is defined

Let’s do a simple ripgrep search, like true greybeards, this time:

Terminal window
cami@onigiri ~/c/r/s/c/s/t/b/r/c/dom [1]> rg "userAgent"

hm… what? No results!

Looks like we are in the wrong place. Let’s take a step back by going into the root of the Chromium source code and searching for mentions of “userAgent” in any .idl file:

Terminal window
cami@onigiri ~/c/r/s/c/s/t/b/r/c/dom > cd ../../../../../
cami@onigiri ~/c/r/s/c/src> rg "userAgent" -g "*.idl"
third_party/blink/renderer/core/frame/navigator_ua.idl
8: [SecureContext] readonly attribute NavigatorUAData userAgentData;
third_party/blink/renderer/core/frame/navigator_id.idl
41: [MeasureAs=NavigatorUserAgent] readonly attribute DOMString userAgent;
third_party/blink/renderer/modules/credentialmanagement/digital_credential.idl
18: static boolean userAgentAllowsProtocol(DOMString protocol);
third_party/blink/web_tests/external/wpt/interfaces/html.idl
2406: readonly attribute DOMString userAgent;
third_party/blink/web_tests/external/wpt/interfaces/ua-client-hints.idl
41: [SecureContext] readonly attribute NavigatorUAData userAgentData;
third_party/blink/web_tests/external/wpt/interfaces/digital-credentials.idl
37: static boolean userAgentAllowsProtocol(DOMString protocol);

From these results, we figure out that the JS userAgent attribute is defined in third_party/blink/renderer/core/frame/navigator_id.idl.

We cd into third_party/blink/renderer/core/frame/ and try searching for the definition of userAgent in C++ code again:

Terminal window
cami@onigiri ~/c/r/s/c/s/t/b/r/c/frame> rg "userAgent" -g "*.cc"
navigator_ua_data.cc
118: // client hints returned by navigator.userAgentData.getHighEntropyValues(),
navigator_id.cc
57: const String& agent = userAgent();
navigator_ua.cc
14:NavigatorUAData* NavigatorUA::userAgentData() {

Okay, we’re getting somewhere. Remember? Our .idl file is called navigator_id.idl, and now, we see some kind of access to a userAgent() function in navigator_id.cc.

We open navigator_id.cc, and find something pretty disappointing:

navigator_id.cc
String NavigatorID::appVersion() {
// Version is everything in the user agent string past the "Mozilla/" prefix.
const String& agent = userAgent();
return agent.Substring(agent.find('/') + 1);
}

We can see a usage of userAgent, yes, but no definition!


I assume here that you set up clangd to be able to go to reference, etc. I’ll probably make a post about it (I just couldn’t help but start writing about patching right away!). In the meantime, you can look at the official Chromium documentation.


We’re going to take advantage of the LSP server.

I press gd in my editor to go to the definition of userAgent, which makes us land in navigator_id.h.

navigator_id.h
namespace blink {
class CORE_EXPORT NavigatorID {
public:
String appCodeName();
String appName();
String appVersion();
virtual String platform() const;
String product();
virtual String userAgent() const = 0; // <---- our cursor jumps to this line
};
} // namespace blink

Uh-oh… userAgent is a virtual function. That can’t be good.

Once again, the clangd LSP server will save us.

I put my cursor on userAgent and type gi to get the list of implementations.

Surprisingly, there is only one, in navigator_base.cc. Right.

Here is how it looks:

navigator_base.cc
String NavigatorBase::userAgent() const {
ExecutionContext* execution_context = GetExecutionContext();
return execution_context ? execution_context->UserAgent() : String();
}

Bingo!

Hardcoding the value of navigator.userAgent

Let’s inject our user agent with some unholy C++:

navigator_base.cc
String NavigatorBase::userAgent() const {
return String("foobar");
// ExecutionContext* execution_context = GetExecutionContext();
// return execution_context ? execution_context->UserAgent() : String();
}

Then, we build Chromium. Grab some popcorn, because that’ll likely take a few minutes at least…

Terminal window
# if you don't have a justfile defined, read the last post to get started :)
just build

One eternity later, the build completes:

ninja: Entering directory `out/Default'
1.80s load build.ninja
build finished
local:175 remote:0 cache:0 cache-write:0(err:0) fallback:0 retry:0 skip:129390
fs: ops: 20150(err:17854) / r:1148(err:0) 55.08GiB / w:110(err:0) 83.14MiB
resource/capa used(err) wait-avg | s m | serv-avg | s m |
pool=link/1 110(0) 4m39.75s |▂ ▂▂▂█ | 4.42s | ▂█▂ |
8m16.89s Build Succeeded: 175 steps - 0.35/s

Let’s open our compiled binary with our dedicated command:

Terminal window
just open

A freshly baked Chromium window appears.

Let’s open DevTools and type navigator.userAgent

Spoofed UA!

… success! We can see that our navigator.userAgent got spoofed correctly!

Using an environment variable to define the user-agent

Now, let’s make it a bit better by using an environment variable, which will allow us to inject the value we want before starting the browser:

navigator_base.cc
String NavigatorBase::userAgent() const {
// START OF PATCH
const char* fp_user_agent = std::getenv("FP_USER_AGENT");
if (fp_user_agent) {
return String::FromUTF8(fp_user_agent);
}
// END OF PATCH
ExecutionContext* execution_context = GetExecutionContext();
return execution_context ? execution_context->UserAgent() : String();
}

We build and open Chromium:

Terminal window
just build-fast
FP_USER_AGENT=toto src/chromium/src/out/Default/chrome

And then we type navigator.userAgent in DevTools:

Chromium with navigator.userAgent being toto

As you can see, the value we put in FP_USER_AGENT got applied successfully.

Generating the patchfile

Once you’ve got a tweak that works well for your purpose, you’ll likely want to preserve it for later use.

For this purpose, we’ll use good old patch files.

Why use patch files instead of git branches?

Using commits on top of the actual Chromium repo is impractical because you would need to find a git host that would host dozens of gigabytes of code. Then, you would need to learn and tweak the whole dependencies syncing system (gclient sync) to work with forks of the various components that you’ll patch.

I am sure that working with pure git is cleaner, but I am not sure that I would ever recoup the time investment involved in getting it working. Also, I’ve tried patch files, and they just work, save for the occasional line numbers change that can be delegated to Claude Code for the most part anyway.

Justfile command

Here’s the just command that I’m using to generate the patch files:

justfile
# Generate a patch from a modified chromium file (fzf selection)
gen-patch-fzf:
#!/usr/bin/env bash
set -e
cd src/chromium/src
selected=$(git diff --name-only | fzf --preview 'git diff {}')
if [[ -z "$selected" ]]; then
echo "No file selected"
exit 1
fi
# Determine output directory based on path
if [[ "$selected" == third_party/blink/* ]]; then
outdir="../../../patches/blink"
elif [[ "$selected" == chrome/* ]]; then
outdir="../../../patches/chrome"
else
outdir="../../../patches/other"
mkdir -p "$outdir"
fi
filename=$(basename "$selected")
git diff -- "$selected" > "$outdir/${filename}.patch"
echo "Wrote $outdir/${filename}.patch"

Generating the patch file

When running it, you’ll get a fzf (fuzzy finding) window that lists all the modified Chromium source files.

You can type to search the one that you want to generate a patch file for.

the gen-patch-fzf window

Once you press enter, it’ll write a file in ./patches/blink/.

There are obviously many ways to handle this; for example, you could create a command that generates patch files for all the modified files in the git folder rather than requiring you to manually create a patch each time you make a modification.

I enjoy this manual approach more than an automated one for now, but might change my mind later.

Here is the end result:

navigator_base.cc.patch
diff --git a/third_party/blink/renderer/core/execution_context/navigator_base.cc b/third_party/blink/renderer/core/execution_context/navigator_base.cc
index 0910e68f91b90..dea62e9496406 100644
--- a/third_party/blink/renderer/core/execution_context/navigator_base.cc
+++ b/third_party/blink/renderer/core/execution_context/navigator_base.cc
@@ -47,11 +48,18 @@ NavigatorBase::NavigatorBase(ExecutionContext* context)
: NavigatorLanguage(context), ExecutionContextClient(context) {}
String NavigatorBase::userAgent() const {
+ const char* fp_user_agent = std::getenv("FP_USER_AGENT");
+ if (fp_user_agent) {
+ return String::FromUTF8(fp_user_agent);
+ }
ExecutionContext* execution_context = GetExecutionContext();
return execution_context ? execution_context->UserAgent() : String();
}
String NavigatorBase::platform() const {
+ const char* fp_platform = std::getenv("FP_PLATFORM");
+ if (fp_platform) {
+ return String::FromUTF8(fp_platform);
+ }
+
ExecutionContext* execution_context = GetExecutionContext();
#if BUILDFLAG(IS_ANDROID)

As you can see, there is a bonus patch for navigator.platform included.

Applying the patch before building

Here is the justfile command that I’m using for building:

justfile
build: patch build-chromium-builder
# ... snip ...

As you can see, it depends on the patch command, that is defined here:

justfile
patch: patch-args patch-patches

I’ll skip patch-args for now and focus on patch-patches:

justfile
# Patch all the non-skipped patchfiles in parallel.
patch-patches:
#!/usr/bin/env bash
set -e
find patches -name "*.patch" ! -name "*.skip" | xargs -P {{build_threads}} -I {} just patch-patch {}

This command:

  1. Finds the patch files
  2. Excludes the ones that end in .skip
  3. Calls just patch-patch for each of those in parallel
justfile
# Apply a given patch file (uses patch command instead of git for parallel safety)
patch-patch patchpath:
#!/usr/bin/env bash
set -e
# Path of the file we want to patch.
target_path=$(grep "^---" {{patchpath}} | head -n 1 | awk '{print $2}' | sed -e 's|^a/||')
cd src/chromium/src
# Save current content hash and mtime before patching
current_hash=$(sha256sum "$target_path" | cut -d' ' -f1)
current_mtime=$(stat -c %Y "$target_path")
# Reset the file by reading from git HEAD (doesn't lock git index)
git show "HEAD:$target_path" > "$target_path"
# Apply the patch using patch command (works on files directly, no git lock)
patch -p1 < ../../../{{patchpath}}
# If content is the same as before, restore original mtime to preserve build cache
new_hash=$(sha256sum "$target_path" | cut -d' ' -f1)
if [[ "$current_hash" == "$new_hash" ]]; then
touch -d "@$current_mtime" "$target_path"
echo "Applied {{patchpath}} (already applied, mtime preserved)"
else
echo "Applied {{patchpath}}"
fi

just patch-patch applies a single patch that’s been specified as an argument.

  1. We extract the target path of the file to patch from the patch’s header
  2. We compute the hash + get the mtime of the target file
  3. We reset the file in git — we do that by reading the file at the HEAD rather than using git checkout, to save time (git can be slow in a codebase of this size)
  4. We apply the patch
  5. If the file didn’t get modified, we put back the mtime we saved earlier. This helps with build caching.

That’s it!

Why this patch is not enough

The patch we just wrote only allows us to inject our user agent in navigator.userAgent; it doesn’t change the actual user-agent header that gets sent over the network.

This is obviously an issue; you’ll need to dig into how HTTP requests are made in Chromium, then pull the thread until you figure out a convenient place where you could inject the value of FP_USER_AGENT.

You’ll also need to update navigator.userAgentData for it to be consistent.

You will need to painstakingly write dozens of patches to fully control your fingerprint while still being consistent.

End note

I hope that this post helped you get a better idea of what patching Chromium looks like.

As you can see, it’s not totally impossible, but there can be some frustrating issues, especially if you don’t know C++.

For example, I struggled for a bit too long to figure out a way to convert a const char* into a String.

What’s next?

I’ll probably talk about setting up clangd or mold for faster linking times.

mail

Stay in the loop

Get notified when we publish new research. No spam, just posts like this.

rss_feed … or follow our RSS feed

Need help with data extraction?

We apply this level of rigor to every data extraction challenge. Let's discuss your project.

Get in touch