Skip to content
NoSrc
Go back

Reversing TeamSpeak 3's Recording Notifications: A Binary Patching Journey

Updated:

This post is a work in progress and is still missing sections.

TeamSpeak is an online voice solution primarily used by gamers. It has a variety of useful features that set it apart from other voice solutions like Discord, such as cross channel communication, priority speakers, and most relevant to this article, built-in audio recording.

When using the built-in audio recording feature, TeamSpeak notifies all users in the channel audibly by announcing “A user has started recording in your channel”. All users in the server are also visually notified by a [Recording] tag next to the recording user’s name.

But what if I could get rid of that notification and use the recording feature without disruption? Sure, it would be trivial to use some other software to do the audio recording outside of TeamSpeak, but that’s not what this is about. I simply wanted to know if it could be done, and how difficult such a task would be.

The recording feature behaves differently than you’d expect; what looks like a local action that happens to notify the server is actually server-coordinated, with the client waiting for acknowledgment before recording begins locally. That distinction only becomes apparent when you try to remove the network call and find that recording stops working entirely.

In the end I was able to remove this network dependency and perform local recordings without notifying the server.

Disclaimer: This research was conducted on a personal installation of TeamSpeak 3 for educational purposes. No modified binaries are distributed here, and nothing in this post is intended to facilitate unauthorized access to or disruption of TeamSpeak servers or other users. The methodology is documented to illustrate reverse engineering techniques, not to encourage misuse.

Table of contents

Open Table of contents

The Target

There are multiple versions of the TeamSpeak client available, at the time of writing this the two clients available for download are TeamSpeak 6 and TeamSpeak 3. This project focuses on TeamSpeak 3, only because it’s my preference and I personally use it. While the client is available on Windows, macOS, and Linux, this project focuses exclusively on the Windows version. TeamSpeak 3 is a Qt-based application that utilizes their proprietary network protocol. The specific version of TeamSpeak 3 I am working with for this project is 3.6.2 which uses Qt version 5.15.2. Qt’s signal and slot mechanism connects UI actions to their underlying functions through a dispatch layer rather than direct calls. Understanding this mechanism is necessary for tracing execution from a UI action to its handler. The client also ships with no debug symbols and no source is publicly available, so all type information and function names visible in this post were reconstructed from the binary.

Static Analysis

After loading the binary into Binary Ninja, string search is the natural starting point when working with a binary with no symbols. I searched for the string “Start Recording”, which is the text on the button that starts recording. That string appears twice in the binary. The first reference leads to what appears to be a UI string initialization method, but the second is more interesting. The second reference is in a method setting up the QActions for the toolbar buttons, where each action is being initialized along with its signal/slot connections.

Most significantly, this is where Qt wires the signal to its slot, and the slot method address is visible directly in the binary. Qt connects the signal and slot with the method QObject::connectImpl which can be seen here:

QAction* rbx_154 = *(arg1 + 0x248)
int32_t var_e8_82 = 0xffffffff
QMetaObject::tr(this: &data_7ff74f5d0ab8, &arg_10, "Start recording", 0)
QAction::setStatusTip(this: rbx_154, &arg_10)
QString::~QString(this: &arg_10)
QAction* rbx_155 = *(arg1 + 0x248)
QMetaObject::tr(this: &data_7ff74f5d0ab8, &arg_18, "Ctrl+Shift+R", 0)
QKeySequence::QKeySequence(this: &arg_10, &arg_18, 0)
QAction::setShortcut(this: rbx_155, &arg_10)
QKeySequence::~QKeySequence(this: &arg_10)
QString::~QString(this: &arg_18)
QAction::setVisible(this: *(arg1 + 0x248), data_7ff74f6a9f3b == 0)
void** rbx_156 = *(arg1 + 0x248)
arg_10.q = QAction::triggered
int32_t* rax_197 = sub_7ff74ee7f73c(0x18)
arg_20.q = rax_197

if (rax_197 == 0)
    rax_197 = nullptr
else
    *rax_197 = 1
    *(rax_197 + 8) = sub_7ff74e3482a0
    *(rax_197 + 0x10) = arg1

struct QMetaObject const* const var_c8_15 = QAction::staticMetaObject
QObject::connectImpl(&arg_18, rbx_156, &arg_10, arg1, nullptr, rax_197.d, nullptr, nullptr)

The key line is *(rax_197 + 8) = sub_7ff74e3482a0. This is where Qt stores the function pointer to the actual handler that executes when the record button is pressed. After defining a struct in Binary Ninja to represent the slot object and renaming the slot method, the same section reads more clearly.

if (start_recording_slot_object == 0)
    start_recording_slot_object = nullptr
else
    start_recording_slot_object->ref_count = 1
    start_recording_slot_object->slot_method = start_recording_wrapper
    start_recording_slot_object->receiving_obj = arg1

struct QMetaObject const* const var_c8_15 = QAction::staticMetaObject
QObject::connectImpl(&arg_18, rbx_156, &arg_10, arg1, nullptr, start_recording_slot_object.d, nullptr, nullptr)

The slot method is named start_recording_wrapper here because Qt uses it for both calling and cleanup depending on the op code passed internally. When invoked, the actual recording logic is in another method it calls. Examining the method that contains the actual start recording logic reveals the launching of a file picker dialog, and a method that is setting a key value pair in a QSqlDatabase, likely the filepath returned from the picker. There is another method that is doing something with the recording status information, indicated by an assertion string. The purpose of this method will become clearer in the dynamic analysis section.

Having located the recording handler, the file picker method, and the database call, I next moved on to finding the network call. To do this I used the symbol search in Binary Ninja to locate common Windows networking API calls. The two candidates to investigate were the Windows API functions WSASend and WSASendTo. I chose to focus on WSASendTo to start as this method is for UDP traffic which aligns with TeamSpeak’s known use of UDP for voice traffic. WSASendTo is only referenced in one method, and the method containing WSASendTo is itself only referenced once. The containing method however is part of a VTable, meaning the call is resolved at runtime rather than determined at compile time. Without knowing the object type at that point in execution, static analysis alone cannot determine what calls it. At this point, rather than continuing to trace statically, I turned to dynamic analysis to observe the actual call chain at runtime.

Dynamic analysis

Before dynamic analysis could begin, TeamSpeak’s launch behavior required some investigation. When launching TeamSpeak with a debugger attached, I encountered an error 0x800702E4 which is ERROR_ELEVATION_REQUIRED. Launching the debugger as administrator does not resolve this issue, but investigating the program manifest explained why. Looking into the TeamSpeak 3 manifest, there is the following section:

<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
  <security>
    <requestedPrivileges>
      <requestedExecutionLevel level="asInvoker" uiAccess="true"></requestedExecutionLevel>
    </requestedPrivileges>
  </security>
</trustInfo>

This tells Windows that the application needs elevated UI access. This allows TeamSpeak’s global hotkeys to function even when a higher privilege window has focus, bypassing Windows’ User Interface Privilege Isolation. This changes the startup pattern of the executable. Windows reads the manifest at launch, validates that the executable is signed and located in a trusted directory, then restarts the process with the appropriate UI access privileges granted. Because of this restart, the debugger must be attached to the second elevated instance after it has fully launched, which requires the debugger to be elevated as well.

Once the debugger is attached it becomes apparent that live debugging presents a problem for a program dependent on network calls and staying connected to a server. If a breakpoint is hit and execution isn’y resumed quickly, the server connection times out and disconnects. This becomes easier to manage by recording the debugging session with WinDbg Time Travel Debugging (TTD), where breaks aren’t live and thus won’t stop the execution and disconnect from the server. The workflow necessary to get a successful TTD session flows like this:

  1. Start TeamSpeak 3
  2. Attach elevated debugger with TTD recording enabled
  3. Perform action you wish to debug
  4. End debug session

In this case, the action we are debugging is the recording action, so the flow of actions I recorded are:

  1. Connect to Server
  2. Begin Recording session
  3. End Recording session
  4. Disconnect from server

Once the recording completed I opened it in Binary Ninja and set a breakpoint at the only method that calls to WSASenTo.

To be continued…

(This is where I will talk about using ttd analysis to get better insight into what is happening in both the network call method and the start_recording method)


Share this post on: