Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add linux hidden_modules plugin #1283

Open
wants to merge 10 commits into
base: develop
Choose a base branch
from

Conversation

gcmoreira
Copy link
Contributor

@gcmoreira gcmoreira commented Oct 1, 2024

This PR introduces a new kernel module scanning technique that is significantly faster and more efficient than the traditional Volatility2 plugin. It uses less memory and I/O, while also catching advanced threats missed by the earlier method.

From kernels 4.2 (93c2e10) struct module allocation are aligned to the L1 cache line size. For i386, amd64, and arm64 architectures, this is usually 64 bytes (or 128 bytes, which still works). However, in x86 this can be changed in the Linux kernel configuration via CONFIG_X86_L1_CACHE_SHIFT. For ARM64 this valued is fixed to 64 bytes. The alignment can also be obtained from the DWARF info i.e. DW_AT_alignment<64>. Unfortunately dwarf2json doesn't support this feature yet.
In kernels < 4.2, alignment attributes are absent in the struct module, meaning alignment cannot be guaranteed. Therefore, for older kernels, it's better to use the traditional scan technique.

It checks whether Linux kernel module addresses are aligned to 64 bytes. If not, it falls back to 1-byte alignment and notifies the user of the adjustment.

Demos

The following is an example of a machine infected with the Reptile rootkit. The machine has 2048MB of RAM.

$ time python3 ./vol.py \
    -r pretty \
    -f ../ubuntu180464bit_4.15.0-213-generic_reptile_infected.core \
    linux.hidden_modules
Volatility 3 Framework 2.10.0      
  |        Address |           Name
* | 0xffffc094f400 | reptile_module

real    0m16.501s
user    0m15.378s
sys     0m0.851s

Here is a Kovid rootkit sample provided by @Abyss-W4tcher :

$ time python3 ./vol.py \
    -r pretty \
    -f ../Ubuntu-jammy_5.15.0-87-generic_kovid.lime \
    linux.hidden_modules
Volatility 3 Framework 2.10.0
  |        Address |  Name
* | 0xffffc09ed4c0 | kovid

real    0m16.932s
user    0m16.187s
sys     0m0.739s

We also observed that advanced malware can modify certain values that are invalid when the module is loaded but have no impact once it's running. This technique enables sophisticated threats to evade detection from existing memory forensics methods.

The following is a slightly modified version of the Kovid rootkit, detectable only through the fast scan method.

$ time python3 ./vol.py \
    -r pretty \
    -f ../dump_ubuntu20.04_5.15.0-87-generic_kovid_99.core \
    linux.hidden_modules
  |        Address |  Name
* | 0xffffc07f8600 | kovid

real    0m18.233s
user    0m17.281s
sys     0m0.676s

@Abyss-W4tcher
Copy link
Contributor

Hello, awesome submission, this feature allows to uncover deeply hidden rootkits which is extremely valuable.

Having worked on this problematic for the 2023 contest, I ran your plugin against an infected sample, but unfortunately got no results. Would you be interested in acquiring this sample ("kovid" rootkit), to check if this might come from a scanning or restrictive constraint issue ?

Thanks again !

@gcmoreira
Copy link
Contributor Author

@Abyss-W4tcher interesting.. sure, could you share that with me?

@Abyss-W4tcher
Copy link
Contributor

A first restricting point might be the MODULE_STATE_LIVE assertion, whereas some rootkits modify this attribute to act "unloaded" :

@gcmoreira
Copy link
Contributor Author

Thanks @Abyss-W4tcher .. easy fix with no impact on performance.

$ time python3 ./vol.py \
	-f ../Ubuntu-jammy_5.15.0-87-generic_kovid.lime \
	linux.hidden_modules
Volatility 3 Framework 2.10.0
Address Name

0xffffc09ed4c0  kovid

real    1m12.489s
user    1m11.930s
sys     0m0.555s

$ time python3 ./vol.py \
	-f ../Ubuntu-jammy_5.15.0-87-generic_kovid.lime \
	linux.hidden_modules \
	--fast
Volatility 3 Framework 2.10.0
Address Name

0xffffc09ed4c0  kovid

real    0m15.385s
user    0m14.835s
sys     0m0.543s

Additionally, classmethod helpers were added, and docstrings were enhanced for improved usability and clarity.
…fast scan method for even better performance, using the mkobj.mod self referential validation used in module.is_valid() as pre-filter

Removed the --heuristic-mode and the module.states validation, since the self referential check is enough by itself
@gcmoreira
Copy link
Contributor Author

@Abyss-W4tcher added your suggestion. It's running even faster now. Thanks

@gcmoreira
Copy link
Contributor Author

Since the fast scan method is performing exceptionally well and can detect threats that the traditional method misses, I think we should rename it, any ideas @Abyss-W4tcher @ikelos ?

Another option would be to move it to a new plugin to ensure users don't overlook it, as it might go unnoticed if it's just an argument.

@Abyss-W4tcher
Copy link
Contributor

Abyss-W4tcher commented Oct 3, 2024

On my take, adding multiple plugins "doing the same thing" might only confuse users.

Maybe removing the vol2 method and implicitely relying on fast might be better (forensic wise) :

  • l1 alignement OK : do the "fast" method
  • l1 alignement NOK : do the "fast" method but instead of aligning to 64 bytes, scan byte per byte / every 8 bytes (pointer size)

I have some older infected samples (~kovid like) on which I can test this idea, I'll keep you informed if it is reliable.

@gcmoreira
Copy link
Contributor Author

maybe better going backwards 64, 32, 16, 8, 1 ... and keeping the already tested address in a set

@gcmoreira
Copy link
Contributor Author

gcmoreira commented Oct 3, 2024

maybe better going backwards 64, 32, 16, 8, 1 ... and keeping the already tested address in a set

hm actually, if we are going to test each byte, it doesn't make sense to test the others. If _validate_alignment_patterns() fails with 64 bytes, we should fallback to scan with 1 byte alignment and that's it.

If you see the following metrics, it still performs really good.

Forcing 8 bytes alignment

$ time ./vol.py -f ../dump_ubuntu20.04_5.15.0-87-generic_kovid_99.core  linux.hidden_modules --fast 
Volatility 3 Framework 2.10.0               
Address Name

0xffffc07f8600  kovid

real    0m31.045s
user    0m29.886s
sys     0m0.820s

Forcing 1 byte alignment

$ time ./vol.py -f ../dump_ubuntu20.04_5.15.0-87-generic_kovid_99.core  linux.hidden_modules --fast 
Volatility 3 Framework 2.10.0
Address Name

0xffffc07f8600  kovid

real    2m34.798s
user    2m33.008s
sys     0m0.837s

The Vol2 method takes 1m9s but it doesn't find it

$ time ./vol.py -f ../dump_ubuntu20.04_5.15.0-87-generic_kovid_99.core  linux.hidden_modules
Volatility 3 Framework 2.10.0
Address Name


real    1m8.985s
user    1m7.025s
sys     0m0.852s

@Abyss-W4tcher
Copy link
Contributor

Abyss-W4tcher commented Oct 4, 2024

Looks great, so it doesn't try _validate_alignment_patterns() with 64, 32, 16, 8 and 1, but only 64, 8 and 1 ?

@gcmoreira
Copy link
Contributor Author

@Abyss-W4tcher only 1 byte and 8 bytes alignments. It was just to see how this would impact performance.

module_address_alignment = 1 # 8 # cls._get_module_address_alignment(context, vmlinux_module_name)

@Abyss-W4tcher
Copy link
Contributor

Ah yes, this looks like a good compromise for me, as it's always preferable to spend more time but still detect advanced threats in the end. Should the vol2 method be left as a reference ?

@gcmoreira gcmoreira marked this pull request as draft October 8, 2024 21:37
@atcuno
Copy link
Contributor

atcuno commented Oct 14, 2024

@gcmoreira @Abyss-W4tcher what is the status of this one? It seems high priority based on it blocking other PRs

…and fall back to a 1-byte alignment scan if addresses aren't aligned to the L1 cache size
@gcmoreira gcmoreira marked this pull request as ready for review October 16, 2024 04:23
@gcmoreira
Copy link
Contributor Author

Okay, I made the fast scan method the default. Removed the vol2 implementation and updated the plugin to fall back to 1-byte alignment scanning if addresses aren't aligned with the L1 cache size. Now it's much easier to use, with less code and significantly more powerful!

Thanks @Abyss-W4tcher and @atcuno for your help and suggestions.

@ikelos this is now ready for review

Copy link
Member

@ikelos ikelos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally pretty good, but a couple of points that have to be fixed before it goes live (even if I have to write new core methods to achieve it!). First up is using child_template rather than directly addressing the structure of the vol.members structure. Secondly shifting the hardcoded limits off to the constants file so they're a bit more obvious than being buried away in the middle of code somewhere.

Otherwise just some documentation other little nitpicks and it should be good to go. 5:)

import contextlib
from typing import List, Set, Tuple, Iterable
from volatility3.framework import renderers, interfaces, exceptions, objects
from volatility3.framework.constants.architectures import LINUX_ARCHS
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, please can we import architectures and then access architectures.LINUX_ARCHS when we want to use it?


if isinstance(modules_addr_min, objects.Void):
# Crap ISF! Here's my best-effort workaround
vollog.warning(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a specific version number now? Is this the kind of thing we can use a filter to detect if it's bad and warn them once at the start of the plugin?

"Your ISF symbols are missing type information. You may need to update "
"the ISF using the latest version of dwarf2json"
)
# See issue #1041. In the Linux kernel these are "unsigned long"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a clang issue as I recall? It almost feels like it would be easier to inject the missing base_type into the symbol_table once we've found out which one was there? That would then work anywhere else you wanted to use it, and could become a general function/pattern that could be pulled out into the framework if it was useful?

break
else:
raise exceptions.VolatilityException(
"Bad ISF! Please update the ISF using the latest version of dwarf2json"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whilst I appreciate the sentiment, this makes me feel like we're telling off our faithful dog "ISF" for having made a mess of the kitchen... 5:P Could we make this a little more formal/professional please, or at least just drop the exclamation mark?

Returns:
The struct module alignment
"""
# FIXME: When dwarf2json/ISF supports type alignments. Read it directly from the type metadata
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comment and the forward thinking parameters. 5:)

mkobj_offset = vmlinux.get_type("module").relative_child_offset("mkobj")
mod_offset = vmlinux.get_type("module_kobject").relative_child_offset("mod")
offset_to_mkobj_mod = mkobj_offset + mod_offset
mod_member_template = vmlinux.get_type("module_kobject").vol.members["mod"][1]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a little bit nasty, I feel there should be something to get to member["mod"] in some way, and the [1] ? I'm pretty sure that from an ObjectTemplate you can get a child template using .child_template('mod')? I really want to find a cleaner way of expressing this before it lands.

@@ -36,6 +35,30 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._mod_mem_type = None # Initialize _mod_mem_type to None for memoization

def is_valid(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we get a docstring here detailling exactly what is_valid means, since we've got is_valid in some places and is_readable in others and people are really gonna need to know exactly what this does from the documentation.


core_size = self.get_core_size()
if not (
1 <= core_size <= 20000000
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These hardcoded limits come about from...? For arbitrary values like 20000000, I'd prefer that they were stashed in constants.linux with a brief comment to describe them, so that they're consistent and can be changed all at once if needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants