TLS Callback Evasion in angr
Introduction
Lately, I have been on a quest to find and fix bugs in open-source software that I use for my personal projects. In this post, I’ll highlight a bug that causes the analysis tool angr to miss functionality on certain architectures and could allow a malware author to evade static analysis.
Scenario
Assume you are given a Windows PE file and would like to determine whether it is malicious.
You run your nifty angr-based malware analyzer on it, and your tool reports that it is benign. You double-check the functions analyzed and note an entry point and a few functions called from it.
You run the executable and observe something strange: it prints a message whose strings appear in neither the entry-point function nor the functions called from it. This message appears to hint at malicious behavior occurring on the machine!
Contrary to what you might think, a lot happens before the entry point runs.
Background
Thread Local Storage (TLS)
Microsoft’s PE format documentation defines TLS as follows:1
The .tls section provides direct PE and COFF support for static thread local storage (TLS). TLS is a special storage class that Windows supports in which a data object is not an automatic (stack) variable, yet is local to each individual thread that runs the code. Thus, each thread can maintain a different value for a variable declared by using TLS.
TLS callbacks support TLS setup and teardown. During startup, they run in sequence before the entry point.2 Typical uses include calling constructors and destructors. This functionality can be abused to evade analysis by detecting analyzers before the main entry point.3
Use in the wild
I found a few examples of TLS callbacks being used for evasion:
- The first public use of TLS callbacks to run code before the entry point was W32.Shrug,4 credited to “roy g biv”.5 Chthon is a descendant file infector.6
- An analysis of a Mustang Panda PlugX sample shows that its DLL loader finds and calls the TLS callbacks of the target DLL.7
- Rhadamanthys uses static TLS callbacks.8
Because attackers use TLS callbacks to hide functionality, these callbacks are important targets for static analysis. But what if the analysis tool fails to parse them correctly?
The TLS Callback Bug in angr
angr is a symbolic execution analysis engine for executables that supports a variety of custom analyzers.
Under the hood, angr uses CLE, a library responsible for parsing and loading targets into a form that angr can analyze. CLE parses PE files, including their TLS callbacks.
CLE’s TLS callback handling is implemented in cle/backends/pe/pe.py:
def _register_tls_callbacks(self, addr):
callbacks = []
callback_rva = AT.from_lva(addr, self).to_rva()
callback = self._pe.get_dword_at_rva(callback_rva)
while callback != 0 and callback is not None:
callbacks.append(callback)
callback_rva += 4
callback = self._pe.get_dword_at_rva(callback_rva)
return callbacks
Can you spot the bug?
TLS callback addresses in 64-bit PE files are 8 bytes wide, not 4!
For a 64-bit PE file with a TLS callback at VA 0x140001000, the buggy parser
reports two TLS callback addresses: 0x40001000 and 0x1, hiding the actual
address. This affects downstream angr analyses that use the enumerated TLS
callbacks.
Microsoft’s PE format documentation notes the architecture-dependent sizes:1
Note: References to 32-bit offsets and index multipliers of 4 apply to systems with 32-bit architectures. In a system based on 64-bit architectures, adjust them as necessary.
The issue was reported and tracked in CLE issue #648. This bug dates back to at least CLE version 5.6.10.5. It affects angr/CLE versions up to and including 9.2.196. The downstream impact depends on how an analysis uses the parsed TLS callbacks.
The Fix
I added a check of the target’s architecture to select the appropriate pointer size and read function. The merged implementation is shown below:
def _register_tls_callbacks(self, addr):
"""
TLS callbacks are stored as an array of virtual addresses to functions.
The last entry is empty (NULL), which indicates the end of the table
"""
callbacks = []
callback_rva = AT.from_lva(addr, self).to_rva()
is_64bit = self.arch.bits == 64
ptr_size = 8 if is_64bit else 4
get_ptr = (
self._pe.get_qword_at_rva if is_64bit else self._pe.get_dword_at_rva
)
callback = get_ptr(callback_rva)
while callback != 0 and callback is not None:
callbacks.append(callback)
callback_rva += ptr_size
callback = get_ptr(callback_rva)
return callbacks
Validation
To validate this fix, I added a unit test that checks a crafted executable with a TLS callback address above 4 GiB, so the upper 32 bits are nonzero:
def test_tls_x64(self):
exe = os.path.join(TEST_BASE, "tests", "x86_64", "windows", "TLS.exe")
ld = cle.Loader(exe, auto_load_libs=False)
tls = ld.tls.new_thread()
assert ld.main_object.tls_used
assert ld.main_object.tls_callbacks == [0x140001000]
assert tls is not None
assert len(ld.tls.modules) == 1
This checks that CLE recovers the full 64-bit callback address rather than misreading it as two 32-bit entries.
You can examine the fix in PR #649. I’d like to give a shout-out to the angr team for the prompt review and merge!
Lessons Learned
- Always check for TLS callbacks.
- Ensure your debugger is configured to break on TLS callbacks.
- MSVC doesn’t always use a
.tlssection for TLS callbacks. Use the TLS data-directory entry to locate the TLS directory.
References
-
Microsoft Learn, PE Format: The .tls Section and TLS Callback Functions. ↩ ↩2
-
Phrack #71, A VX Adventure in Build Systems and Oldschool Techniques. Discusses TLS directory injection, callbacks before the main entry point, and pitfalls such as calling APIs that spawn threads inside a TLS callback. ↩
-
TLS callbacks also work inside DLLs and run before
DllMainduring process attachement. See SANS Internet Storm Center’s diary entry 32580. ↩ -
Interview with roy g biv. Discusses W32.Shrug and the early use of TLS callbacks. ↩
-
Peter Ferrie, Chiton paper (PDF). ↩
-
Kien Manowar, Diving into a PlugX sample of Mustang Panda group. ↩
-
Cybereason, Rhadamanthys stealer analysis. ↩