IDA 9.3 b1 macOS arm64 UAF crash

Hey,

I think I have identified a use-after-free bug in libshiboken that’s bundled with IDA and is highly timing dependent.

Ignored it until now because I thought it was my own plugin causing this, but when I started looking into this, it became clear this isn’t inside my plugins (or any plugin for that matter, since this also happened with all plugins disabled).

IDA / OS / hardware context:

  • IDA Pro 9.3.251224
  • Apple M4 Max / 128GB memory / macOS Tahoe
  • Memory pressure was around 85GB - 90GB at time of testing
  • CPU pressure around 10% - 15%

IDA’s error handler consistently caught the error, but I figured running IDA in autonomous mode skips the error handler registration. Running under lldb didn’t yield any crash after 20 iterations, indicating a race condition.

After a bit of fiddling I figured I’ll run MallocGuardEdges=1 /Applications/IDA Professional 9.3.app/Contents/MacOS/ida to check if there’s some kind of buffer overflow, which yielded zero crashes. This reinforced that it was likely due to timing (since MallocGuardEdges has a non-trivial impact on timing).

I realised if the error was a segfault and it’s timing related, there must be a ref to uninitialized memory somewhere.

To make sure this wasn’t an initialization issue, and given the fact that the crash happens around 35% of the time, I suspected this crash is due to a use after free in memory that happens to be in a contested memory region. Lo and behold, MallocScribble=1 /Applications/IDA Professional 9.3.app/Contents/MacOS/ida made IDA crash 100% of the time (MallocScribble makes malloc’s free overwrite all freed memory with 0x55).

At this point my observations looked like this:

Environment Crash Rate
Normal ~35%
MallocScribble=1 100% (5/5)
MallocGuardEdges=1 0% (0/5)

This was pretty annoying to debug since I couldn’t debug it, so I tried dtrace tracing it at 5 kHz and found the last good stack to be ida -> libida.dylib -> idapython3.dylib -> Python -> PyImport_ImportModuleLevelObject -> dlopen.

Out of sheer bruteforce debugging at this point I ran MallocScribble=1 MallocStackLogging=1 MallocStackLoggingNoCompact=1 /Applications/IDA Professional 9.3.app/Contents/MacOS/ida.

This yielded:

qt.qpa.fonts: Populating font family aliases took 1043 ms. Replace uses of missing font family "Courier" with one that exists to avoid this cost. 
Assertion failed: (previous_refcount >= refs), function uniquing_table_node_release_internal, file uniquing_table_mutator.c, line 1288.

Which is interesting because now it looked like a clear use-after-free or double-free bug.

I kept brute forcing and figured since lldb disabled ASLR and MallocScribble made it crash 100% of the time, I’ll try that:

for i in $(seq 1 5); do
    lldb -b \
        -o "settings set target.disable-aslr false" \
        -o "env MallocScribble=1" \
        -o "process launch -- -B" \
        -o "process handle SIGSEGV -n true -p true -s true" \
        -o "continue" \
        -o "bt all" \
        "/Applications/IDA Professional 9.3.app/Contents/MacOS/ida" 2>&1 | tail -80
    echo "=== Run $i done ==="
done

That gave me:

Trying lldb with proper settings...
(lldb) target create "/Applications/IDA Professional 9.3.app/Contents/MacOS/ida"
Current executable set to '/Applications/IDA Professional 9.3.app/Contents/MacOS/ida' (arm64).
(lldb) settings set target.disable-aslr false
(lldb) env MallocScribble=1
(lldb) process launch -- -B
ida(90897,0x1ee2bf000) malloc: enabling scribbling to detect mods to free blocks
2026-01-13 16:13:17.041001+0100 ida[90897:35867907] [qt.qpa.fonts] Populating font family aliases took 620 ms. Replace uses of missing font family "Courier" with one that exists to avoid this cost.
Process 90897 launched: '/Applications/IDA Professional 9.3.app/Contents/MacOS/ida' (arm64)
Process 90897 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x5f5f796f72740060)
    frame #0: 0x000000018151daa4 libsystem_platform.dylib`_platform_strlen + 4
libsystem_platform.dylib`_platform_strlen:
->  0x18151daa4 <+4>:  ldr    q0, [x1]
    0x18151daa8 <+8>:  adr    x3, 0x18151d9e0           ; ___lldb_unnamed_symbol329
    0x18151daac <+12>: ldr    q2, [x3], #0x10
    0x18151dab0 <+16>: and    x2, x0, #0xf
Target 0: (ida) stopped.

... etc. ..

Lets try again for the stack traces:

cat > /tmp/lldb_crash.lldb << 'EOF'
settings set target.disable-aslr false
env MallocScribble=1
breakpoint set -n main
run -B
breakpoint delete 1
continue
EOF

# Run with the script and capture all output
for i in $(seq 1 10); do
    echo "=== Attempt $i ===" 
    lldb --source /tmp/lldb_crash.lldb \
        "/Applications/IDA Professional 9.3.app/Contents/MacOS/ida" << 'CMDS' 2>&1 | grep -A100 "EXC_BAD_ACCESS\|stop reason" | head -60
thread backtrace
frame info
register read x0 x1 x19 x20 pc lr
quit
CMDS
    
    if [ ${PIPESTATUS[0]} -eq 0 ]; then
        echo "Exited normally"
    fi
done

That gave me:

=== Attempt 2 ===
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0xffffff006e6f6970)
    frame #0: 0x000000018151daa4 libsystem_platform.dylib`_platform_strlen + 4
    ...
(lldb) thread backtrace
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0xffffff006e6f6970)
  * frame #0: 0x000000018151daa4 libsystem_platform.dylib`_platform_strlen + 4
    frame #1: 0x000000012b393e84 libshiboken6.abi3.6.8.dylib`___lldb_unnamed_symbol876 + 52
    frame #2: 0x000000012b395c0c libshiboken6.abi3.6.8.dylib`___lldb_unnamed_symbol883 + 128
    frame #3: 0x0000000124fe1df4 Python`builtin_getattr + 136
    ...

=== Attempt 3 ===
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x1)
    frame #0: 0x00000001292d391c libshiboken6.abi3.6.8.dylib`___lldb_unnamed_symbol945 + 112
    ...
(lldb) thread backtrace
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x1)
  * frame #0: 0x00000001292d391c libshiboken6.abi3.6.8.dylib`___lldb_unnamed_symbol945 + 112
    frame #1: 0x0000000122e5a758 Python`cfunction_call + 72
    ...
    frame #26: 0x0000000122df37a4 Python`PyObject_CallFunctionObjArgs + 56
    frame #27: 0x00000001292b7aac libshiboken6.abi3.6.8.dylib`___lldb_unnamed_symbol772 + 1544
    frame #28: 0x00000001292bbe68 libshiboken6.abi3.6.8.dylib`Shiboken::initShibokenSupport(_object*) + 112
    frame #29: 0x0000000129121460 Shiboken.abi3.so`PyInit_Shiboken + 304
    frame #30: 0x0000000122f7858c Python`_PyImport_RunModInitFunc + 88
    ...

=== Attempt 10 ===
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x5f657361635f656b)
    frame #0: 0x000000012b02391c libshiboken6.abi3.6.8.dylib`___lldb_unnamed_symbol945 + 112
    ...

I can see two different crash sites here:

  1. Crash in _platform_strlen (Attempt 2): Shiboken calls strlen on a freed string pointer
  2. Crash in ___lldb_unnamed_symbol945 (Attempts 3, 7, 10): Shiboken’s custom import hook dereferences freed memory directly

Findings from the invalid memory addresses:

  • 0xffffff006e6f6970 - scribbled memory containing “pion”
  • 0x1 - near-NULL pointer
  • 0x5f657361635f656b - scribbled memory containing “ke_case_” (part of make_snake_case_name inside libshiboken6.abi3.6.8.dylib)

Edit: I really wanted to find out what “pion” / “noip” was, but only found references in ./libclang.dylib, i.e. BLRNoIP, GPR64common_and_GPR64noip, etc. Looks like the pointer was originally initialized to -1 (i.e. 0xFF…). Maybe Qt’s QVariant /QString’s tagged representations (i.e. struct QString { Data *d; ... }, if LSB set, maybe small string inlining? idk but neither LE / BE variants of this string appear in any binary)?

Reading the backtrace again (attempt 3):

frame #30: Python`_PyImport_RunModInitFunc        <- Python importing a C extension
frame #29: Shiboken.abi3.so`PyInit_Shiboken       <- Shiboken module init entry point
frame #28: Shiboken::initShibokenSupport()        <- Setting up Shiboken infrastructure
frame #27: ___lldb_unnamed_symbol772              <- Internal Shiboken function
frame #26: PyObject_CallFunctionObjArgs           <- Calls back into Python
  ... (recursive Python import machinery via Shiboken's custom __import__ hook) ...
frame #19: ___lldb_unnamed_symbol945 + 28         <- Shiboken's import hook (earlier call)
  ... (more nested imports) ...
frame #0:  ___lldb_unnamed_symbol945 + 112        <- CRASH in same hook (later call)

The crash occurs in ___lldb_unnamed_symbol945, which appears to be Shiboken’s custom import hook. It’s called recursively during nested module imports, and at some point it dereferences a string pointer that has already been freed.

tl;dr

The use-after-free occurs during Shiboken::initShibokenSupport() when Python is initializing PySide6. It’s a race condition in the library’s startup code where a string gets freed while Shiboken’s import hook still holds a reference to it.


But why stop here?

I wanted to find out if it’s related to threading:

CRASHES=0
for i in $(seq 1 10); do
    rm -f /Users/int/dev/isa-harvester/armgen/programs/template_madness.i64 2>/dev/null
    PYTHONDONTWRITEBYTECODE=1 PYTHONNOUSERSITE=1 timeout 30 /Applications/IDA\ Professional\ 9.3.app/Contents/MacOS/ida -A /Users/int/dev/isa-harvester/armgen/programs/template_madness >/dev/null 2>&1
    [ $? -eq 139 ] && ((CRASHES++))
done
echo "Crashes with PYTHONDONTWRITEBYTECODE: $CRASHES/10"

Output:

Testing with PYTHONDONTWRITEBYTECODE...
Crashes with PYTHONDONTWRITEBYTECODE: 10/10

These env vars mainly impact timing, making the crash more likely.

Final Summary

What I found:

  1. Bug Type: Use-after-free race condition
  2. Location: libshiboken6.abi3.6.8.dylib (PySide6 6.8.0) during Shiboken::initShibokenSupport()
  3. Trigger: Python module import initialization at IDA startup
  4. Observation: Happens even with all plugins disabled and no input file

Reliable Reproduction:

MallocScribble=1 /Applications/IDA\ Professional\ 9.3.app/Contents/MacOS/ida -B

Backtrace (bottom-up):

Python`_PyImport_RunModInitFunc + 88
  -> Shiboken.abi3.so`PyInit_Shiboken + 304
    -> libshiboken6`Shiboken::initShibokenSupport(_object*) + 112
      -> libshiboken6`___lldb_unnamed_symbol772 + 1544
        -> Python`PyObject_CallFunctionObjArgs + 56
          -> ... (recursive imports via Shiboken's __import__ hook) ...
            -> libshiboken6`___lldb_unnamed_symbol945 + 112  <- CRASH
               (or _platform_strlen when the hook passes a freed string)

:duck::duck::duck:

2 Likes

I traced it down, and it was my fault – idapythonrc.py:

import os
import sys
import site

print("[idapythonrc.py] Starting initialization...")

# Enable PyQt5 compatibility shim (IDA 9.2 uses PySide6 by default)
os.environ["IDAPYTHON_USE_PYQT5_SHIM"] = "1"
print(f"[idapythonrc.py] Set IDAPYTHON_USE_PYQT5_SHIM={os.environ.get('IDAPYTHON_USE_PYQT5_SHIM')}")

# IDA's Python directory with built-in modules (PyQt5, PySide6)
ida_python_dir = "/Applications/IDA Professional 9.2.app/Contents/MacOS/python"

# Ensure IDA's Python modules are accessible (they should be by default)
if ida_python_dir not in sys.path:
    sys.path.insert(0, ida_python_dir)

# Add the virtual environment's site-packages to sys.path
# Use append instead of insert so IDA's built-in modules take precedence
ida_venv = os.path.join(os.path.expanduser("~"), ".idapro", "pythonvenv")
site_packages = os.path.join(ida_venv, "lib", "python3.13", "site-packages")
if os.path.exists(site_packages):
    sys.path.append(site_packages)
    print(f"[idapythonrc.py] Added venv site-packages: {site_packages}")

print("[idapythonrc.py] Initialization complete.")

Note the ida_python_dir – the python integration was actually loading libshiboken6.abi3.6.8.dylib from IDA 9.2’s python path. Apparently the library has changed its ABI slightly enough in 9.3 b1 (custom patches?) for it to be compatible enough with IDA 9.2’s variant for it to work … most of the time.

Apologies for the noise.

2 Likes

Thank you for the update!

1 Like

Incredible detail. Don’t be sorry for the noise! Thank you for taking the time to write this up! I learned a good bit here and fixed a lingering bug in my end.

1 Like