Third Party Client Detection
RuneLite has some tricks up its sleeve to help it accurately detect third party clients! In this guide we are going to cover some of the clever ways that RuneLite uses to detect and share third party client information with OSRS. This is one current advantage to using the native C++ client over the Java client as there are far fewer client detection methods currently in place.
As always, this guide is purely for education purposes and is not meant for reverse engineering, hacking, botting, or breaking any of RuneLite or Jagex's terms of service. This guide is for my own personal knowledge and reference. It is not intended to be a comprehensive list of all third party client detection methods, and more methods will almost certainly come up in the future that aren't covered in this guide.
Mouse Hook DLL
The mouse hook DLL is a more recent addition to RuneLite's arsenal of detection systems. RuneLite ships with a DLL file which registers a low-level mouse hook called: LLMHF_INJECTED (information here). The DLL file is called: rlicn_{os.arch}.dll and is packaged as a resource within a JAR file loaded as part of the RuneLite bootstrap process. The {os.arch} will be something like x86 or x64 representing your CPU architecture.
This detection mechanism is only applicable to Windows machines and is designed to detect when a mouse click is injected into the game process, i.e. AHK scripts or mouse clicks done over a Desktop sharing program like TeamViewer, Chrome RDP, or ParSec.
The MSLLHOOKSTRUCT Structure
When a Windows application wants to monitor mouse activity globally (across the entire operating system, not just within its own window), it uses the SetWindowsHookEx API to install a Low-Level Mouse Hook (WH_MOUSE_LL).
Every time a mouse event occurs (a movement, a click, or a scroll), Windows intercepts the event and passes a pointer to an MSLLHOOKSTRUCT to the hook procedure before the event reaches the target window.
In C++, the struct looks like this:
typedef struct tagMSLLHOOKSTRUCT {
POINT pt; // The x/y coordinates of the cursor
DWORD mouseData; // Wheel scroll data or specific X-button data
DWORD flags; // Event-injected flags (This is the important part)
DWORD time; // The timestamp of the message
ULONG_PTR dwExtraInfo; // Additional info associated with the message
} MSLLHOOKSTRUCT, *PMSLLHOOKSTRUCT, *LPMSLLHOOKSTRUCT;This structure acts as the single source of truth for the OS regarding a mouse event.
The LLMHF_INJECTED Flag
The flags field is a DWORD (a 32-bit unsigned integer) that contains bitwise flags describing the origin of the event. When a human physically clicks a standard USB mouse, the hardware interrupt travels through the kernel driver to the OS. Windows processes this event normally, and the event-injected bits within the flags field remain 0. If a program uses a user-space Windows API like SendInput(), mouse_event(), or high-level wrappers like Java's Robot class to simulate a click, Windows automatically flips bit 0 of the flags field to 1.
This specific bit is known as LLMHF_INJECTED (0x00000001). Typically, the easiest way to circumvent this process is to patch the client at runtime to always hardcode a 0 value for the llimc value in the client class. This means that regardless irrespective the origin of the click event, whenever the field is accessed it will always return 0. You can see an example of this in the patching document.
JVM Agents & Platform Information
Before a user logs into the game with RuneLite, Jagex will construct and populate a buffer containing lots of information about the user's platform. This data and buffer construction all takes place in a class called PlatformInfo which is part of the vanilla client.
The following table details the information included in the login packet which is built in part by PlatformInfo.write(). It's important to note that the data is written in this specific order, and is invaluable in helping to identify which obfuscated fields map to which actual data.
i.e. cw was written last in the PlatformInfo.write() method so even though PlatformInfo's field order are changed we know that cw corresponds to the agent value. Thus, to spoof the agent value, we can simply hardcode the value of cw to whatever we want via client patching.
| Field | Example |
|---|---|
| osType | 1 |
| osVersion | 12 |
| vendor | 2 |
| javaMajor | 11 |
| javaMinor | 1 |
| javaPatch | 25 |
| maxMemory | 4096 |
| cpuCores | 32 |
| systemMemory | 16365 |
| signers | sun.nio.ch.FileDispatcher |
| Unknown_3 | (unknown) |
| Unknown_4 | (unknown) |
| process | java.exe |
| parentProcess | java.exe |
| Unknown_7 | (unknown) |
| Unknown_8 | (unknown) |
| Unknown_9 | (unknown) |
| Unknown_10 | (unknown) |
| Unknown_11 | (unknown) |
| Unknown_12 | (unknown) |
| Unknown_13 | (unknown) |
| clientName | 304+411+client4122yq\nclient19537qj\nnrc.RuneLite297start\nnrc.RuneLite274main\nnrcpk.KrakenLoader+21main |
| agent | JvmAgent.jar |
This information is written to a Buffer (in the same way packets are) and sent over the network to Jagex servers. There are 2 key pieces of information here that we care about for client detection:
clientNameagent
The client name very closely resembles the call stack trace that RuneLite generates (more information here) and the agent contains the name of a JAR file being used as a JVM agent. More information on JVM agents can be found here.
It should be obvious that we definitely don't want RuneLite or Jagex being aware that an agent is in use modifying classes at runtime. Most clients will patch the field value for agents so that when it is read a hardcoded value of: "" for no agents present.
Care has to be taken in how this field is patched. The PlatformInfo.write() method accepts a Buffer writer as an argument and within the buffer writer object a fixed size byte array is provisioned based on the data being written. This means that if you directly modify the client name (callstack) or agent fields within the write() method you will almost certainly either:
- Have a byte array undersized where OSRS servers discard the packet, and you see: "RuneScape has been updated, please restart client"
- Have a byte array too small to hold all the modified data you are sending and you will get a
java.lang.ArrayIndexOutOfBoundsException
Neither outcome is desirable, so make sure that when you are patching the clientName or agent fields you are patching them at the class level (globally) for all methods in the PlatformInfo class. This way, when the write() method is called it will always provision the correct sized byte array for the modified data you are sending.
Code Signing & Assertions Check
This check answers a simple question for Jagex: "Is this the real, untampered RuneLite client jar, or did someone rebuild it?" It does this without shipping a hash of the bytecode. Instead it leans on a feature built into Java itself, jar signing, and folds the answer into the same PlatformInfo login buffer we covered above (it is the signers field in that table). As a bonus, it also quietly leaks whether you started the client with Java assertions enabled, which is a dead giveaway that you are running from an IDE or a development launch.
As with the rest of this guide, obfuscated names like bq, cp.az, and lw.wj are specific to one client revision (1.12.38 here) and will change on every update. Match on behaviour, not on names.
Background: what "signers" even means
When Jagex (or RuneLite) builds the official client jar, they run jarsigner over it. That embeds two files in the jar: a .SF signature file and a .RSA certificate block (you can see these yourself, look inside client-<version>.jar under META-INF/ and you will find RL.SF and RL.RSA). The certificate is essentially a tamper-evident seal, only the holder of RuneLite's private key can produce it.
Java exposes this at runtime through a single method on every Class object:
Object[] signers = SomeClass.class.getSigners();- If the class was loaded from a properly signed jar,
getSigners()returns the certificate chain that signed it. That array is identical for every user on the planet running the official jar, because everyone is running the exact same signed bytes. - If the class was loaded from an unsigned jar (for example, one you rebuilt from decompiled sources, or classes loaded loose from a
build/directory),getSigners()returnsnull.
So "did you tamper with the client?" collapses into "does this class still carry RuneLite's signature?". That is the entire idea behind this check.
How the fingerprint is built
The client does not send the certificate itself. It sends a small integer derived from it. Here is the actual obfuscated routine that builds the value (it runs early, right as the client wires up its three main cache archives, well before the login screen appears):
// Obfuscated (1.12.38). This same body appears in two places, cp.az(...) and pw.ao(...).
vt vt2 = eb.pt; // eb.pt is the singleton PlatformInfo instance
vt2.bq = (Arrays.hashCode(oe.cz.xr.getClass().getSigners()) >> 2) * 721999872
+ -1694189056
+ (lw.wj - 1) * 1337221120;
vt2.cp = client.ri(); // the call stack fingerprint, covered in the next sectionDeobfuscated, it reads like this:
// What it actually means
Client client = oe.cz; // the live client instance
Callbacks callbacks = client.xr; // the RuneLite "Callbacks" hook object...
Class<?> hookClass = callbacks.getClass(); // ...whose real class is net.runelite.client.callback.Hooks
int signerHash = Arrays.hashCode(hookClass.getSigners()); // 0 if the jar is unsigned
boolean assertionsOff = lw.wj; // JVM synthetic $assertionsDisabled flag
platformInfo.signers = (signerHash >> 2) * MAGIC_A
+ MAGIC_B
+ (assertionsOff ? 0 : -1) * MAGIC_C;Let us unpack the three moving parts, because each one is doing real work:
oe.cz.xr.getClass().getSigners()—oe.czis the client instance. Itsxrfield is thenet.runelite.api.hooks.Callbacksobject, which at runtime is RuneLite'snet.runelite.client.callback.Hooksclass (this class lives in the signedclient.jar). So the client is asking "is the class that implements my RuneLite hooks still signed?". This is a smart choice of victim class:Hooksis the bridge that makes RuneLite work at all, so it has to be present and has to come from the client jar.Arrays.hashCode(...) >> 2— the signer array is turned into a single int withArrays.hashCode, then shifted right by two bits. For the official signed jar this is a fixed, known number. For an unsigned jargetSigners()isnull, andArrays.hashCode(null)is0, so this whole term becomes0.lw.wj— this is not a real field anyone wrote. It is the JVM's synthetic$assertionsDisabledflag that the compiler adds tolw(some obfuscated Widget class). It is computed once per class like this:java// Compiler-generated, decompiled static { wj = !lw.class.desiredAssertionStatus(); }So
lw.wjistrue(1) in normal launches (assertions off) andfalse(0) when the JVM is started with-ea/-enableassertions. The(lw.wj - 1)term is therefore0normally and-1under-ea. That is how a purely development-only flag sneaks into your login fingerprint.
There is also an implicit check hiding here: if the RuneLite hooks were never installed, client.xr would be null and oe.cz.xr.getClass() would throw a NullPointerException during login. In other words, "you must have RuneLite's hooks wired up" is enforced for free, just by how the value is read.
How it lands on the wire
The value is stored in the PlatformInfo bq field and written out inside PlatformInfo.write() as a short (two bytes):
// Inside the obfuscated PlatformInfo.write() (vt.az)
xy2.bv(this.bq * -799972875, -1542382992); // bv() writes the low 16 bits, big-endianThe * -799972875 is just the obfuscator's matching "decode" multiplier (it is the modular inverse of the 1749910109 the constructor multiplies by), a standard trick to make static analysis harder. The practical result is that only the low 16 bits of the computed number reach Jagex.
Because of the specific magic constants, those 16 bits always land on a fixed lattice. Working the unsigned-jar case all the way through (signerHash = 0) gives clean, memorable numbers:
| Launch condition | getSigners() | Assertions | Value sent (signers short) |
|---|---|---|---|
| Rebuilt / unsigned jar, normal launch | null → hash 0 | off | 0x0200 (512) |
Rebuilt / unsigned jar, -ea launch | null → hash 0 | on (-ea) | 0x2200 (8704) |
| Official signed jar, normal launch | RuneLite cert | off | a specific fixed value determined by RuneLite's signing certificate |
Jagex knows what the official signed jar produces. Anything else, an unsigned rebuild sending 0x0200, or a value shifted by the -ea term, stands out immediately against the known-good number.
Legitimate vs. illegitimate values
- Legitimate: you are running the untouched, officially signed
client.jarthat RuneLite's own launcher downloaded and verified, and you did not pass-ea.getSigners()returns RuneLite's certificate, the assertions term is zero, and the short matches what every other real user sends. - Illegitimate: you decompiled the client, changed something, and rebuilt it. Your rebuilt jar is unsigned,
getSigners()returnsnull, and you send0x0200(or0x2200from an IDE run with assertions on). Either way the number no longer matches the official jar's fingerprint.
This is exactly why the "cleanest" approach to a RuneLite-based client is to never modify the client jar on disk at all, and instead patch behaviour at runtime (see the patching guide). Runtime patching with a tool like ByteBuddy redefine keeps the class name, fields, and, critically, the loaded jar's signature intact, so getSigners() still returns RuneLite's certificate.
How to stay consistent with a real client
You mitigate this check by making sure all three inputs match a normal player, not by faking the number:
Run the official signed jars, unmodified. Let RuneLite's own launcher fetch and verify
injected-client.jar/client.jar, then add your own code on top (extra jars on the classpath, runtime patches). Do not rebuild, re-sign, or hand-edit the client jar, the moment you do,getSigners()stops matching. If your tooling verifies the client hash before launch (as a safety gate), that also guarantees you are running the same signed bytes as everyone else.Never ship
-eato end users. Java assertions are a development convenience. If your run configuration or launcher passes-enableassertions/-ea,lw.wjflips and yoursignersshort shifts by the assertions term (0x0200→0x2200in the unsigned example). Only enable assertions when testing locally and never on an account you care about.Make sure RuneLite's hooks are actually installed. Remember the implicit
nullcheck, ifCallbacks(client.xr) is missing you willNullPointerExceptionduring login. A correctly loaded RuneLite client already satisfies this, but a broken custom bootstrap that skips hook installation will fail here.If you must patch it, patch the field, not the jar. In the rare case you cannot run a signed jar (for example a fully rebuilt client), the only safe option is to spoof the
bqfield read the same way the agent and call stack are handled: substitute every read of thesignersfield across the wholePlatformInfoclass so the buffer allocates the correct size (see the warning in the JVM Agents & Platform Information section about undersized/oversized buffers). You would need to know the exact value a genuine signed client emits for that revision, and pin it. For most projects this is unnecessary overkill, running the real signed jar is simpler and safer.
⚠️ Reminder: the honest, low-risk path is to keep the official signed client bytes untouched and do everything additive at runtime. Faking
getSigners()is only ever needed when you have already given up jar integrity, at which point you are fighting an uphill battle against a value only Jagex knows for certain.
Random.dat
The random.dat file is one of Jagex’s oldest client tracking mechanisms. Even though you are playing on RuneLite, RuneLite is simply running the underlying vanilla Old School RuneScape client, which is what actually generates and reads this file. If you look in your computer's home directory (usually C:\Users\<YourUsername>\ on Windows or ~ on macOS/Linux), you will likely find it sitting there.
I.e., RuneLite doesn't have anything to do with the random.dat file in your root dir it's purely a Jagex mechanism.
What is inside random.dat?
Despite its ambiguous name, the file is not a dump of random game data or cache files. It is a tiny binary file traditionally only 24 bytes in size—that contains a randomly generated Unique Identifier (UID).
When you load the OSRS client for the very first time, the game checks your home directory. If random.dat does not exist, the client generates a new, completely unique cryptographic string and saves it to that file. Every time you launch the client or log in after that, the client reads this UID and sends it to Jagex's servers during the login handshake.
Jagex uses random.dat as a persistent digital fingerprint for your machine. Because it is stored outside the standard game cache, it often survives game updates, client installs, and cache clearing. Jagex can use the UID as a trust metric. If an account is suddenly accessed from a new IP address, but the random.dat UID matches the one normally used by the account owner, the system might treat the login as a slightly higher risk as Jagex is able to tell It's still your account from the same computer.
random.dat files are unique per character not per account.
Why "Nuking" the File Fails
Many proxy setups simply delete random.dat before every login. From a data analytics standpoint, this is a massive red flag. Legitimate players do not reinstall their game or clear their application data every time they log in. Generating a brand-new UID for every single session tells the anti-cheat system that the client is either deliberately tampering with its tracking files or operating in a volatile, non-standard environment. This makes the account highly suspicious before the character even takes a step in-game.
A better approach is to use profile sandboxing. To effectively stay undetected regarding random.dat, the system needs to maintain strict isolation:
- 1 Account = 1 Proxy = 1 Persistent UID: Every distinct account (or group of accounts operating from a single "household" IP) should be assigned its own unique
random.datfile.
When Account A is launched, the client must inject or load Account A's specific random.dat. When the session ends, any changes to that file should be saved to Account A's profile. By doing this, Jagex's servers see a consistent UID logging in from a consistent IP address over weeks or months. This approach more aptly mirrors a real player returning to the game on their personal computer.
Call Stack Check (Client Fingerprint)
The "Call Stack Fingerprint" is an integrity check used to verify that the client was started through legitimate means. By examining the Java call stack at the very beginning of the application's lifecycle, the client can detect if it was launched normally (e.g., via the official executable or JAR file) or if it was injected/loaded by an unauthorized third-party tool.
How the Call Stack Fingerprint is Generated
The client builds and cements this fingerprint incredibly early in the loading process to prevent spoofing. It achieves this using a combination of Java 11's ConstantDynamic (link) and dependency injection frameworks (like Google Guice).
Here is the step-by-step execution flow:
- The Core Builder Method (
yq): The client contains a specific method (e.g.,yq()) responsible for reading the current JVM stack trace and building the fingerprint string. ConstantDynamicBootstrapping: The calls to the builder method are structured asConstantDynamicbootstrap callsites. Because of how constant dynamic works, the JVM executes this method during the class linking/preload phase.- Caching the First Execution: Constant dynamic guarantees that the bootstrap method is only executed once. The result is cached and used as a constant value for every subsequent call. This "cements" the fingerprint before malicious code can easily hook or modify it.
- Triggered by Dependency Injection (
@Inject): The very first evaluation is typically triggered when RuneLite callsinjectMembers()on the client object. Because Guice processes the@Injectannotations immediately upon client startup, the resulting call stack naturally includes the legitimateRuneLite.mainandRuneLite.startexecution paths. - Assembly and Storage: The resulting string is truncated, concatenated, and ultimately dumped into the
PlatformInfofield to be sent as a login packet.
⚠️ Important By the time Bytebuddy modifies the class at runtime, it is already too late and the callstack has already been built and cached. Modifying the actual callstack method
yqat this point won't help as the value has already been calculated and stored in a field within the client. You must patch the place where the callstack is actually sent (i.e. PlatformInfo.write()) to ensure that the callstack value that was pre-computed is modified before it is sent to the server.
Decompiled Code Examples
Below is an example of what the obfuscated routines look like when properly decompiled.
// Method responsible for truncating the fingerprint (first 3 chars)
public static void cr() {
rc = client.km(System.currentTimeMillis() % 1000L + client.tz(0L), 3);
}
// Triggered incredibly early via Guice dependency injection
@Inject
public void wb() {
vd = client.km(System.currentTimeMillis() % 1000L + client.tz(0L), 3);
}
// Assembles the final string to be dumped into PlatformInfo
public static String yq() {
return rc + vd + client.yq(0L); // yq(0L) returns the cached ConstantDynamic stack trace
}Legitimate vs. Illegitimate Execution
Because the stack trace fingerprint acts as a historical record of exactly how the Java Virtual Machine loaded the client classes, the resulting string can vary wildly depending on the launch method. However, there is only one clean stack trace that is guaranteed to be consistent across all users when RuneLite is legitimately started.
Legitimate Execution Paths
A valid fingerprint will contain the expected class hierarchy of the official launcher. Legitimate ways to start the client include:
- Launching the official
runelite.exeor Jagex Launcher executable. - Executing the official JAR file directly via the command line (e.g.,
java -jar RuneLite.jar). - In these cases, the call stack will accurately reflect standard Java bootstrap mechanisms and RuneLite's internal
main()methods.
Note: Reflection can be used to launch the client, i.e., via a program which wraps the RuneLite launcher. However, it is key that the RuneLite launcher is executed to start the normal flow. If the RuneLite client is launched directly via reflection, the call stack will be mostly correct but missing a line. Wrapper → RuneLite Launcher → RuneLite Client = OK Wrapper → RuneLite Client = WRONG