Tooling · frida · 2026-05-14 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 ⧉ copy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // qt-discover.js const qt = Process . getModuleByName ( 'Qt6Core.dll' ); function scan ( pattern ) { const hits = qt . enumerateExports (). filter ( e => e . name . includes ( pattern )); console . log ( '=== ' + pattern + ' (' + hits . length + ' hits) ===' ); hits . forEach ( e => console . log ( ' ' + e . name )); return hits ; } [ '?constData@QString@' , '?data@QString@' , '?utf16@QString@' ]. forEach ( scan ); globalThis . discover = scan ; console . log ( '\n[+] discover(pattern) also available for ad-hoc scans' ); Run it: bash ⧉ copy 1 frida -l qt-discover.js HackPass.exe Output on a Qt 6.11 / Windows build: text ⧉ copy === ?constData@QString@ (1 hits) === ?constData@QString@@QEBAPEBVQChar@@XZ === ?data@QString@ (2 hits) === ?data@QString@@QEAAPEAVQChar@@XZ ?data@QString@@QEBAPEBVQChar@@XZ === ?utf16@QString@ (1 hits) === ?utf16@QString@@QEBAPEBGXZ 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. Run. bash ⧉ copy 1 frida -l qt-qstring-trace.js HackPass.exe Script. javascript ⧉ copy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 // qt-qstring-trace.js const MIN_LEN = 6 , MAX_LEN = 64 , DEDUP = 16 ; const SKIP = [ ':/' , '/qt-' , 'qrc:' , 'file:' , 'image:' ]; const qt = Process . getModuleByName ( 'Qt6Core.dll' ); const hooks = [ [ 'constData' , '?constData@QString@@QEBAPEBVQChar@@XZ' ], [ 'data' , '?data@QString@@QEAAPEAVQChar@@XZ' ], [ 'utf16' , '?utf16@QString@@QEBAPEBGXZ' ], ]; const recent = []; function ok ( s ) { if ( ! s || s . length < MIN_LEN || s . length > MAX_LEN ) return false ; for ( let i = 0 ; i < s . length ; i ++ ) { const c = s . charCodeAt ( i ); if ( c < 0x20 || c > 0x7E ) return false ; } for ( const p of SKIP ) if ( s . startsWith ( p )) return false ; if ( recent . includes ( s )) return false ; recent . push ( s ); if ( recent . length > DEDUP ) recent . shift (); return true ; } hooks . forEach (([ label , sym ]) => { const fn = qt . findExportByName ( sym ); if ( ! fn ) return console . log ( '[!] not found: ' + sym ); console . log ( '[+] hooking ' + label + ' @ ' + fn ); Interceptor . attach ( fn , { onEnter ( args ) { const qstr = args [ 0 ]; const dataPtr = qstr . add ( 8 ). readPointer (); const size = qstr . add ( 16 ). readPointer (). toInt32 (); if ( size < MIN_LEN || size > MAX_LEN || dataPtr . isNull ()) return ; let s ; try { s = dataPtr . readUtf16String ( size ); } catch ( _ ) { return ; } if ( ok ( s )) console . log ( '[' + label + ' len=' + size + '] ' + s ); } }); }); For HackPass. Type the master password one character at a time in the UI and the Frida console will show this: text ⧉ copy [constData len=6] hackpa [constData len=7] hackpas [constData len=8] hackpass 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 c...