Malops: Singularity
Analysis of a stealthy and persistent LKM rootkit :3

Welcome to the fourth episode of my malware analysis series — this is a slightly different version where I am tackling a challenge from Malops in preparation for the PJMR. I may take another look at this sample and create an actual report following the previous larping but, who got time for that.
This sample is pretty similar to the sample I looked at during my previous writeup HTB Sherlock: APTNightmare2. Analysis of a memory dump that leads to… | by Cwrw | Jan, 2026 | Medium, except this sample appears to contain more functionality. This sample also appears to be a copy of (or based on) Singularity Linux Kernel Rootkit with New Feature Prevents Detection.
Just a quick introduction to Malops — it was recommended to me by Nana Parker and so far, I have been very impressed. If there’s a paywall on this site I haven’t found it yet; it's a really nice repository of Malware analysis challenges in a CTF format, similar to HTB Sherlocks. Enough glazing, lets rip apart this sample :P.
Resources:
Introduction and setup:
Scenario: Our primary web server, critical to our daily operations, has been compromised. Over the past few weeks, our network monitoring tools have been flagging unusual outbound communications to an unknown command-and-control server on an unconventional port. The Digital Forensics and Incident Response (DFIR) team was immediately activated to investigate the anomaly. Initial analysis of the running processes and network connections on the live system revealed nothing out of the ordinary, suggesting a sophisticated attacker attempting to maintain stealth. Suspecting a kernel-level threat, the DFIR team captured a full memory dump of the compromised server for offline analysis. During the memory analysis, the team uncovered traces of a sophisticated Linux rootkit. This rootkit was actively hiding its presence and maintaining persistent access to our server. The DFIR team has successfully recovered the malicious kernel modules from the memory image. As a malware analyst, you have been provided with the recovered malicious modules. Your objective is to perform a thorough analysis of the rootkit and determine its capabilities.
Questions:
Task 1: What is the SHA256 hash of the sample?
Nice easy one to get started with our analysis here — take your pick on how to get it :).
Get-FileHash .\singularity.ko
# - - #
Algorithm Hash Path
SHA256 0B8ECDACCF492000F3143FA209481EB9DB8C0A29DA2B79FF5B7F6E84BB3AC7C8 C:\Users\Cwrw-Mal\Desktop\cha…
Task 2: What is the name of the primary initialization function called when the module is loaded?
Now to start with the real analysis for this sample. I loaded my sample in both Ghidra and IDA Free and jumped between both for my analysis. This is because I prefer the disassembly and graph views of IDA, but there is no decompiler, so Ghidra steps in nicely there :).
Onto finding the main initialization function, we can search the function names in IDA to try and find references to init and we see a few entries returned. Reviewing these names, we get an insight into some of the functionality of this rootkit.
Function list:
Eventually we come across singularity_init which is most likely the initialization function we are looking for. Let's check it out and confirm our suspicion. The code shows that this function just makes a set of calls to the other functions identified in the previous screenshot. Based on the names of these functions we can assume — for now — that these provide the following capabilities:
Reset/fix issues with the rootkit and/or specific functions (
reset_tainted_init)Stealthy file interactions (
hiding_open_init,hiding_directory_init,hiding_stat_init)Privilege escalation (
become_root_init)Stealthy network interactions (
hiding_icmp_init,hiding_tcp_init)
We also see that the singularity_init function also shows that its alternative name is init_module. We can safely assume that either singularity_init or init_module is the answer this question is looking for.
Init bruv:
Task 3: How many distinct feature-initialization functions are called within above mentioned function?
This is an easy one after question two. Just count the number of unique call operations that get made.
Task 4: The reset_tainted_init function creates a kernel thread for anti-forensics. What is the hardcoded name of this thread?
Lets start off by opening up the reset_tainted_init function and seeing what’s there. Pretty quickly we can see a call operation looks to create a thread — kthread_create_on_node. There’s a good chance that this call has the functionality that the question is looking for.
Code graph :*
Let’s check-out the documentation for this API call here we can see the arguments accepted by this API. In reverse order it is thread name, node, data, threadfn. If we read the code block prior to this API call we can see the arguments being passed to this API, IDA does us a nice favor and pulls the string directly into the comments for the operation. The thread name in this instance in zer0t.
Linux man page — kthread:
Task 5: The add_hidden_pid function has a hardcoded limit. What is the maximum number of PIDs the rootkit can hide?
Opening up the add_hidden_pid code we can see that at the start there is a value hidden_count added to the ecx register. Later in the code we can see that this register is compared against a static hex value 20h aka 32 in decimal.
Add_hidden_pid code block:
This was clearer in Ghidra’s code decompiler:
Decompiled add_hidden_pid:
Task 6: What is the name of the function called last within init_module to hide the rootkit itself?
This is a nice and easy one, if we go back and check the initialization module from questions 2 and 3 we will see what the last function called is.
Task 7: The TCP port hiding module is initialized. What is the hardcoded port number it is configured to hide (decimal)?
If we go to the hiding_tcp_init function we can follow the chain to the actual hook code that will carry out the hiding. Following this will lead us to hooked_tcp4_seq_show, which after a quick glance at the code we can see that IDA has already added some helpful information into the comments of this function. IDA has extracted the remote IP address being used :).
Hooked_tcp4_seq_show code block:
To get the port number for this question takes a little endianness ball knowledge (or your favorite LLM of choice :3…) TLDR; Linux network structs store port numbers in Network Byte Order which are big endian, whereas x86–64 operates in little endian.
Shortly after the code block that contains the IP address, we can see that a hex value of 0A146h is also passed into a register. Considering its position in the code block and the lack of other hardcoded information, this likely contains the port number used. First, we need to convert the hex from 0xA146 (big endian) into the network byte order (little endian), which is 0x46A1. Converting this value from hex gives us the decimal value 18081.
Task 8: What is the hardcoded “magic word” string, checked for by the privilege escalation module?
To find this we follow a similar process of finding the actual code we need. We start with the become_root_init call, we trace that to the hooks that get loaded, in this case 10 different hooks looking for the answer. Looking at the hooks that get loaded here we can start with the top one hook_getuid.
Become root hooks:
Skimming through the code here we can see a comment added by IDA showing MAGIC=babyelephant. This is the value we are looking for.
Task 9: How many hooks, in total, does the become_root_init function install to enable privilege escalation?
We answered this question during question 8.
Task 10: What is the hardcoded IPv4 address of the C2 server?
We answered this question during question 7.
Task 11: What is the hardcoded port number the C2 server listens on?
We can find this by looking at the function names again, if we look closely we can see a function named spawn_revshell. Considering this is likely the mechanism in which this rootkit calls-home, we can dissect and see if it contains our network information.
Looking at the first block of code, we can see pretty clearly that it sets-up the network information for the call-back with the r8 and r9 registers.
Spawn_revshell code block:
Task 12: What network protocol is hooked to listen for the backdoor trigger?
To answer this question we really need to identify how/where the spawn_revshell function gets called. A nice and easy way to identify this is to click the function name and hit X on your keyboard to display xrefs which will identify where else in the code this function/address has been referenced. In this case we see that it is present in a function named hook_icmp_rcv.
Now we probably shouldn’t trust the bad guys naming schemes to be accurate… but in this case it does what it says on the tin :p.
XREFS for spawn_revshell:
Task 13: What is the “magic” sequence number that triggers the reverse shell (decimal)?
To answer this piece we need to apply our newly acquired endianness ball-knowledge and dissect the hook_icmp_rcv function. There’s not a lot of helpful information present in the code blocks to answer this question. We do see the same IP identified above, we do see references to a trigger_ip variable but nothing similar for the port, we also see that the function loads functions and values/vars to registers and calls a queued function but nothing that helps us answer this question.
hook_icmp_rcv code block:
To make life a little easier for ourselves, let's take a look at this in the decompiler. Here the logic makes a little more sense, and we can see how exactly the trigger_ip variable comes into play. In the same if conditional that the trigger_ip is validated, we see another hardcoded value get compared, -0x30f9 (or 0xcf07 in the disassembly).
I didn’t know how or why these two values were the same, so I had to ask Jonny Google, which said something about signed short variables vs raw hex bytes and that the decompiler is interpreting the raw as the signed short — I still don’t really understand, but we move.
hook_icmp_rcv decompiled:
Now knowing what we know about network structs vs x86–64, we can apply the same logic against the raw hex 0xcf07 and translate it into LE where we get 0x07cf aka 1999.
Task 14: When the trigger conditions are met, what is the name of the function queued to execute the reverse shell?
We actually see this get called back in question 13.
Task 15: The spawn_revshell function launches a process. What is the hardcoded process name it uses for the reverse shell?
If we were paying attention, we would have identified this during our analysis of the spawn_revshell function. It's still present in the question 11 screenshot ;).



