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

feat(ksymbols): reimplement ksymbols #4464

Merged
merged 4 commits into from
Jan 22, 2025
Merged

Conversation

oshaked1
Copy link
Contributor

@oshaked1 oshaked1 commented Dec 25, 2024

1. Explain what the PR does

The previous ksymbols implementation used a lazy lookup method, where only symbols marked as required ahead of time were stored. Trying to lookup a symbol that was not stored resulted in /proc/kallsyms being read and parsed in its entirety.
While most symbols being looked up were registered as required ahead of time, some weren't (in particular symbols needed for kprobe attachment) which incurred significant overhead when tracee is being initialized.

This new implementation stores all symbols, or if a requiredDataSymbolsOnly flag is used when creating the symbol table (used by default), only non-data symbols are stored (and required data symbols must be registered before updating). Some additional memory usage optimizations are included, for example encoding symbol owners as an index into a list of owner names, and also lazy symbol name lookups where the map of symbol name to symbol is populated only for symbols that were looked up once.

From measurements I performed, the extra memory consumption is around 21MB (from ~159MB to ~180MB when running tracee with no arguments on my machine).

Under the hood, this ksymbols implementation uses a generic symbol table implementation that can be used by future code for managing executable file symbols.

A significant advantage gained by storing all non-data symbols is the ability to lookup a function symbol that contains a given code address, a feature that I plan to use in the future.

This PR closes #4463 and renders #4325 irrelevant (because /proc/kallsyms reads no-longer happen "spontaneously").

Copy link
Collaborator

@NDStrahilevitz NDStrahilevitz left a comment

Choose a reason for hiding this comment

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

Nice work overall, though I do have some comments in mind.

pkg/utils/symbol_table.go Show resolved Hide resolved
pkg/utils/symbol_table.go Show resolved Hide resolved
pkg/utils/symbol_table.go Show resolved Hide resolved
pkg/utils/symbol_table.go Outdated Show resolved Hide resolved
pkg/utils/symbol_table.go Outdated Show resolved Hide resolved
pkg/utils/environment/kernel_symbols.go Outdated Show resolved Hide resolved
pkg/utils/environment/kernel_symbols.go Outdated Show resolved Hide resolved
pkg/ebpf/tracee.go Show resolved Hide resolved
@oshaked1 oshaked1 force-pushed the kallsyms branch 7 times, most recently from a37c7cd to f3e5990 Compare December 29, 2024 10:58
@yanivagman yanivagman linked an issue Dec 29, 2024 that may be closed by this pull request
@oshaked1 oshaked1 force-pushed the kallsyms branch 2 times, most recently from e5c5324 to eaa12ab Compare January 1, 2025 15:41
@oshaked1
Copy link
Contributor Author

oshaked1 commented Jan 1, 2025

I added an additional memory optimization - kernel symbols now only store the lower 48 bits of the address with the assumption that all addresses begin with 0xffff. We ignore any symbols whose address doesn't start with 0xffff, which is only percpu symbols. This allows us to encode the address and owner index together which eliminates 8 bytes per symbol for a total memory saving of around 3-4MB.

Copy link
Collaborator

@NDStrahilevitz NDStrahilevitz left a comment

Choose a reason for hiding this comment

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

I have one critical request to make: please avoid the mixed mutex transactions in the kernel symbols table. From experience this tends to cause transaction mixes where one write operation will happen in the middle of a mixed operation for example. Imagine the following methods m1 and m2 where m1 is w and m2 has rw. The following could occur:

  1. m2 - r
  2. m1 - wait for w
  3. m2 - release r
  4. m1 - back for w
  5. m1 - release
  6. m2 - back to w, wiht r assumptions changed due to m1.

Please either pick for each method that it is R or W and make the lock last for the whole operation. You could even opt for a regular mutex instead of a RWMutex, I don't think we have very frequent reads or writes to this struct anyway.

@oshaked1
Copy link
Contributor Author

oshaked1 commented Jan 2, 2025

Symbols lookups could be very frequent in the future (stack trace processing). Using a write lock for the entire duration of KernelSymbolTable.UpdateFromReader will prevent symbol lookups for a significant duration. The same applies to SymbolTable.LookupByName.

In both functions, the write operations only add new data, they don't change or remove existing data. In the case of SymbolTable.LookupByName, the worst case scenario for an outdated assumption means we add the same name to symbol mapping twice (the added data will always be the same). For KernelSymbolTable.UpdateFromReader, the worst case scenario is the same owner gets added twice to idxToSymbolOwner, but all data remains valid.

I could solve the issue with UpdateFromReader by adding a third lock that makes sure only a single update operation can happen at a time (which prevents 2 concurrent goroutines from wanting to add a new symbol owner).

@oshaked1
Copy link
Contributor Author

oshaked1 commented Jan 2, 2025

I could also change the API of the kernel symbol table so that reading /proc/kallsyms happens once when creating the symbol table, and if the user wants to update it, he must create a new one. This prevents the need for locks at all.

It also solves a race condition where if a lookup happens between kst.symbols.Clear() and kst.symbols.AddSymbols(symbols) the lookup will fail.

@oshaked1
Copy link
Contributor Author

oshaked1 commented Jan 13, 2025

@geyslan some of your remaining comments are regarding lock behavior in kernel_symbols.go. As I proposed in a previous comment, I could change the API such that KernelSymbolTable is RO and when it needs to be updated (currently only happens when a kernel module is loaded) a new one is created instead. This would completely remove the need for locks in this file.

WDYT? @NDStrahilevitz @yanivagman it would be great if you could weigh in as well

@NDStrahilevitz
Copy link
Collaborator

@oshaked1 I worry that jt would expose an easy attack against tracee, simply load and unload a module many times. of course this is already suspicious behavior, and not the only "smoke screen" tactic possible, but adding another one isn't great. That said considering it is not the only such tactic maybe it shouldn't be a blocker for the option.

a way to circumvent this altogether would be if you could cache the delta per module load. if we know for sure that the module in a load loop is the same one, there's no need to calculate the differences each time. although this might be too memory heavy.

Overall no strong opinion on going that way, going RO has many obvious benefits, but the exploit surface slightly worries me.

@oshaked1
Copy link
Contributor Author

This attack is also a problem with the current implementation, because we clear the underlying symbol table and add the symbols from /proc/kallsyms again. The RO implementation is actually more resistant to this attack becuase lookups can still happen normally while a new kernel symbol table is being created, and only when it's ready t.kernelSymbols will be replaced atomically.

@NDStrahilevitz
Copy link
Collaborator

This attack is also a problem with the current implementation, because we clear the underlying symbol table and add the symbols from /proc/kallsyms again. The RO implementation is actually more resistant to this attack becuase lookups can still happen normally while a new kernel symbol table is being created, and only when it's ready t.kernelSymbols will be replaced atomically.

True, you're right. So, on my end, I see no issue with moving to an RO implementation.

@geyslan
Copy link
Member

geyslan commented Jan 13, 2025

This attack is also a problem with the current implementation, because we clear the underlying symbol table and add the symbols from /proc/kallsyms again. The RO implementation is actually more resistant to this attack becuase lookups can still happen normally while a new kernel symbol table is being created, and only when it's ready t.kernelSymbols will be replaced atomically.

True, you're right. So, on my end, I see no issue with moving to an RO implementation.

Me neither. 👍🏼

@oshaked1 oshaked1 force-pushed the kallsyms branch 2 times, most recently from ce508b3 to 5008a8b Compare January 13, 2025 16:28
@oshaked1
Copy link
Contributor Author

@NDStrahilevitz @geyslan I changed KernelSymbolTable to RO and guarded t.kernelSymbols behind an atomic getter and setter.

@oshaked1 oshaked1 force-pushed the kallsyms branch 4 times, most recently from cd5b576 to 47d48e1 Compare January 14, 2025 10:17
@@ -123,6 +122,9 @@ type Tracee struct {
policyManager *policy.Manager
// The dependencies of events used by Tracee
eventsDependencies *dependencies.Manager
// A reference to a environment.KernelSymbolTable that might change at runtime.
// This should only be accessed using t.getKernelSymbols() and t.setKernelSymbols()
kernelSymbols unsafe.Pointer
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why using unsafe.Pointer and not *environment.KernelSymbolTable?

Copy link
Contributor Author

@oshaked1 oshaked1 Jan 15, 2025

Choose a reason for hiding this comment

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

It's just to ensure that it's only accessed using the safe getter and setter.

Comment on lines 142 to 149
func (t *Tracee) getKernelSymbols() *environment.KernelSymbolTable {
return (*environment.KernelSymbolTable)(atomic.LoadPointer(&t.kernelSymbols))
}

func (t *Tracee) setKernelSymbols(kernelSymbols *environment.KernelSymbolTable) {
atomic.StorePointer(&t.kernelSymbols, unsafe.Pointer(kernelSymbols))
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm don't understand why we need those... What does the atomic protect from?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@yanivagman The atomic operations protect from simultaneous reads of t.kernelSymbols and an update that replaces it. While there is probably no actual risk, go defines such simultaneous access as a data race so it's just to be safe.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Shouldn't we place this file under environment? I don't think this logic will have any use out of ksymbols context, isn't that so?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I plan to use it in the future for ELF symbols

This implementation stores all symbols, or if a `requiredDataSymbolsOnly`
flag is used when creating the symbol table, only non-data symbols are saved
(and required data symbols must be registered before updating).
This new implementation uses a generic symbol table implementation that is
responsible for managing symbol lookups, and can be used by future code for
managing exeutable file symbols.
After running the init function of a kernel module, the kernel frees the memory that was allocated for it but doesn't remove its symbol from kallsyms.
This resulsts in a scenario where a subsequent loaded module can be allocated to the same area as the free'd init function of the prevous module.
This could result in 2 symbols at the same address, one is the free'd init function and another from the newly loaded module.
This caused an undeterminism in which symbol is used by the hooked_syscall event, which only used the first symbol that was found, resulting in random test failures.
This commit changes the hooked_syscall event to emit one event for each found symbol.
Copy link
Collaborator

@NDStrahilevitz NDStrahilevitz left a comment

Choose a reason for hiding this comment

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

LGTM after all the changes, nice work.

The thread stack area was extracted by finding the VMA containing the SP of the new thread,
but because the SP might be just past the end of its allocated VMA (top of the stack), sometimes the correct VMA was not found.
@geyslan
Copy link
Member

geyslan commented Jan 20, 2025

@yanivagman I still need to check RSS of this.

@geyslan
Copy link
Member

geyslan commented Jan 20, 2025

@yanivagman I still need to check RSS of this.

grep -E ' [tT] ' /proc/kallsyms | wc -l
174972

On my system, the memory usage increased from 32.3MB (main) to 62.2MB (PR) at startup. During oscillation, it reached lows of 22MB (main) and 42MB (PR). Initially, the PR showed an increase of approximately 30MB, but it stabilized to around ~20MB in the long run.

@geyslan geyslan requested a review from yanivagman January 20, 2025 16:06
@geyslan
Copy link
Member

geyslan commented Jan 22, 2025

I'm merging this since we've already discussed regarding the limit of RSS increase, and as explained above it's below.

@geyslan
Copy link
Member

geyslan commented Jan 22, 2025

/fast-forward

This comment was marked as resolved.

@geyslan geyslan merged commit e113f04 into aquasecurity:main Jan 22, 2025
41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
4 participants