Runtime Instrumentation of Qt6 Apps with Frida - Part 1: Getting Visibility
Contents
We will use HackPass for practicing instrumentation by running each frida script against it.
The scripts target Qt6 / Windows. macOS readers - please adapt the symbol names etc. yourself for Mac.
At least three things make Qt6 different from other thick clients. QString isn’t a C string, method dispatch hides behind QMetaObject::activate, and Qt-exported symbols are mangled while the thick client binary is usually stripped.
1. See every QString that gets read
Problem.QString stores text as UTF-16 in a heap buffer with a separate size field, refcounted and copy-on-write through QArrayDataPointer.
strcmp / strncpy-style hooks see nothing useful because the app never touches a C string.
Solution. Hook constData(), data(), and utf16() - the three buffer accessors. They fire on every read of a QString’s UTF-16 buffer. Filter by ASCII-printability and length range. Prints whatever the app actually reads at runtime.
Why these and not toUtf8 / toLatin1 directly? The exported conversion methods like QString::toUtf8 etc. do not execute from app code, instead the static helper like ?toUtf8_helper@QString@@... is called. To hook a conversion you hook the helper, not the public method.
Find the symbols. Mangled names vary slightly between Qt6 builds. qt-discover.js scans the three accessor patterns at load:
javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// qt-discover.js
constqt=Process.getModuleByName('Qt6Core.dll');functionscan(pattern){consthits=qt.enumerateExports().filter(e=>e.name.includes(pattern));console.log('=== '+pattern+' ('+hits.length+' hits) ===');hits.forEach(e=>console.log(' '+e.name));returnhits;}['?constData@QString@','?data@QString@','?utf16@QString@'].forEach(scan);globalThis.discover=scan;console.log('\n[+] discover(pattern) also available for ad-hoc scans');
Three symbols qt-qstring-trace.js hooks: ?constData@QString@@QEBAPEBVQChar@@XZ, ?data@QString@@QEAAPEAVQChar@@XZ (the non-const overload), and ?utf16@QString@@QEBAPEBGXZ. If your build’s enumerator shows different names, swap them into the script.
The password QString grows by one character per keystroke and constData fires every time QML, a property binding, or the meta-object reads the new value. The same trace also captures HackPass’s registry path (Software\HackPass\app), the backend’s loopback host (127.0.0.1), the dynamic vault path, every UI string (Master password, Unlock your vault, Intentionally vulnerable - do not store real passwords).
None of this can usually be seen with strings HackPass.exe as these are all runtime values.
2. Tap signals and slots
Problem. Clicks and UI events become method calls through QMetaObject::activate. There’s no per-signal symbol to breakpoint - Qt’s build tools generate the dispatch code, and slots resolve at runtime through the metaobject.
Solution. Hook activate. Read the sender pointer, the sender’s className() via the metaobject, and the local signal index (resolved to a name via QMetaMethod). That gives a live call-graph of every signal emission in the process.
This helps you find what signal fires on each user action. For example - “click unlock” → clicked() on QQuickButton. Each line also includes the address of the QObject that emitted the signal; pass that address into the metaobject walker (next section) to enumerate every method the object exposes. Every signal that fires is a candidate to intercept later, whether the next thing you care about is vault decrypt, a network request, or a license check.
// qt-signal-tap.js
constSKIP_CLASSES=newSet(['QAbstractEventDispatcher','QGuiApplication','QQuickApplication','QQuickItem','QInputMethod','QWindow','QQuickWindow','QQuickApplicationWindow','QAnimationDriver','QAbstractAnimation','QThread','QPointingDevice','QQuickShaderEffect','QQuickShaderEffectSource','QQuickItemLayer','QQuickAnchors','QQuickIconLabel','QQuickText','QQmlTimer','QQmlConnections','QQuickRectangle','QQuickMaterialPlaceholderText','QQuickMaterialTextContainer',]);constSKIP_PREFIXES=['QSG'];constqt=Process.getModuleByName('Qt6Core.dll');constcandidates=qt.enumerateExports().filter(e=>e.name.startsWith('?activate@QMetaObject@')&&/PEB[UV]1/.test(e.name)&&e.name.includes('PEAPEAX'));if(candidates.length===0)thrownewError('QMetaObject::activate not found');consttarget=candidates[0];constfn=(sym,ret,args)=>{consta=qt.findExportByName(sym);if(!a)thrownewError('symbol not found: '+sym);returnnewNativeFunction(a,ret,args);};constclassName=fn('?className@QMetaObject@@QEBAPEBDXZ','pointer',['pointer']);constmethodOffset=fn('?methodOffset@QMetaObject@@QEBAHXZ','int',['pointer']);constmethodAt=fn('?method@QMetaObject@@QEBA?AVQMetaMethod@@H@Z','void',['pointer','pointer','int']);constmethodName=fn('?name@QMetaMethod@@QEBA?AVQByteArray@@XZ','void',['pointer','pointer']);constmSlot=Memory.alloc(16),baSlot=Memory.alloc(24);functionisNoise(cls){if(SKIP_CLASSES.has(cls))returntrue;for(constpofSKIP_PREFIXES)if(cls.startsWith(p))returntrue;returnfalse;}functionresolveSignalName(metaObj,localIdx){try{methodAt(mSlot,metaObj,methodOffset(metaObj)+localIdx);methodName(baSlot,mSlot);constp=baSlot.add(8).readPointer();if(p.isNull())returnnull;consts=p.readCString();if(!s||!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s))returnnull;returns;}catch(_){returnnull;}}console.log('[+] hooking '+target.name+' @ '+target.address);Interceptor.attach(target.address,{onEnter(args){constcls=className(args[1]).readCString();if(isNoise(cls))return;constlocalIdx=args[2].toInt32();constname=resolveSignalName(args[1],localIdx);if(name)console.log('[signal] '+args[0]+' '+cls+'::'+name);elseconsole.log('[signal] '+args[0]+' '+cls+' #'+localIdx);}});
For HackPass. Click around. Every emission prints sender pointer + sender class + local signal index. The unlock-button click shows clicked() firing on the Material-styled QQuickButton. Pressing any Material button fires pressed / released / clicked on a QQuickAbstractButton. Grab any sender pointer from the output - the metaobject walker (next) takes one to enumerate the class’s full API.
3. Walk the metaobject
Problem. Method names live in the metaobject, not in the symbol table. On a stripped binary the symbol table tells you nothing.
Solution. Given a QObject*, walk its metaobject and print every method, signal, slot, and property with full signatures.
Some QObjects aren’t directly accessible from QML - you can only find them through the signals they emit. Use signal-tap (section 2) to capture their addresses first, then hand each one to the walker.
Load both scripts into the same Frida session so the pointers captured by signal-tap stay valid in the REPL:
The walker reaches the derived class’s QMetaObject through the object’s vtable, then parses the metaobject’s data / stringdata arrays directly. The output lists every method, signal, slot, and Q_INVOKABLE, with the local index per class - which is what the next script needs to invoke them.
For HackPass.walk(VaultManager) dumps inherited QObject methods first (the destroyed / objectNameChanged / deleteLater block), then VaultManager’s own - signals like stateChanged / unlockFailed / tamperedShutdown, then the full Q_INVOKABLE attack surface (stateValue, vaultPath, selectFile, unlock, reUnlockAfterAutoLock, save, lock, autoLock, close, createNew).
walk(PolicyClient) shows what the app exposes to the policy backend - fetch, the methods it calls when server flags arrive, state-change signals. Each line shows a “local” index - the local index is what the next script needs to actually call a method.
4. Call any Q_INVOKABLE from outside
Problem. Interacting with the bridge means clicking through the UI to trigger each method. This is not easily scriptable, especially for fuzzing.
Solution. Given a QObject* and a method name, build the argument list and call via QMetaObject::invokeMethod. Basically bypassing all QML based UI interaction.
Load qt-invokable-call.js alongside signal-tap and the walker from previous steps, so you can interact with the VaultManager pointer from the same Frida session:
// qt-invokable-call.js
functionvirtualMetaObject(qobj){constvtable=qobj.readPointer();constmetaObjFnPtr=vtable.readPointer();// slot 0 on MSVC for Q_OBJECT
returnnewNativeFunction(metaObjFnPtr,'pointer',['pointer'])(qobj);}functionfindStaticMetacall(mo){for(letoff=16;off<=48;off+=8){try{constp=mo.add(off).readPointer();constr=Process.findRangeByAddress(p);if(r&&r.protection.includes('x'))returnp;}catch(_){}}thrownewError('static_metacall not found in QMetaObject');}globalThis.invoke=function(qobj,localIdx,argv){constmo=virtualMetaObject(qobj);constsm=findStaticMetacall(mo);newNativeFunction(sm,'void',['pointer','int','int','pointer'])(qobj,0,localIdx,argv);};globalThis.callNoArg=function(qobj,localIdx){constargv=Memory.alloc(8);argv.writePointer(NULL);invoke(qobj,localIdx,argv);};globalThis.callBool=function(qobj,localIdx,value){constargv=Memory.alloc(16);constdata=Memory.alloc(1);data.writeU8(value?1:0);argv.writePointer(NULL);argv.add(8).writePointer(data);invoke(qobj,localIdx,argv);};console.log('[+] invoke / callNoArg / callBool ready');
For HackPass. Passing the VaultManager pointer and lock’s local index to callNoArg locks the vault.
Part 2 will cover
Premium-gate bypass through the bridge
Single-hook defeat of all five anti-debug points
Vault decryption boundary
SSL pinning bypass
Full client-side bypass chain end-to-end
References
Qt6
QString - implicit sharing and UTF-16 storage that the buffer-accessor hooks rely on