Found while building tools/testhost's regression suite for the Critical #5 block-size fix: VCF_CalcCoeff_LPF/_HPF/_BPF advance the coefficient pointer once per (sample, filter-section) pair, but a 4th-order filter uses FILTER_MAX_ORDER/2 = 2 cascaded 2nd-order sections per sample. VCF_SetBufsize only allocated pCoef with bufsize entries, not bufsize*sections, so any host block between 4097 and 8192 samples with a 4th-order filter selected wrote past the allocation. Confirmed with AddressSanitizer before the fix: heap-buffer-overflow, WRITE of size 8, exactly 8 bytes past a 393216-byte (8192 x sizeof(vcf_coef_t)) allocation. Fixed by sizing pCoef for the worst case. Re-verified with ASan across blockSize 4097/8192/16384/20000 - zero errors after the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011dhtwRLARk4eiPngcQykLJ
8.7 KiB
8.7 KiB
Hardening todo
Findings from a code-review pass over src/plug and src/synth (2026-07-27, branch hardening).
Critical — crashes, memory corruption (fixed, commit a65b71a)
- NRPN controller ID caused an out-of-bounds write on the audio thread.
JaySynth::handleController(src/plug/JaySynth.cpp:899) now bounds-checksmidiCC_info.IDagainstNUM_MIDI_CONTROLLERSbefore indexinglast_midiCC_info[](NRPN IDs can reach 16383, array is sized 1024). - Negative controller IDs from patch files could corrupt memory.
JaySynthMidiCC::add/remove(src/plug/JaySynthMidiCC.cpp:433-460) now also reject< 0, not just out-of-range-high. - Null-pointer dereference on malformed/unparsable patch or bank files.
bankImportXmland the.xmpbranch ofloadPatchFromFile(src/plug/PluginProcessor.cpp:860-885, 949-975) now null-check XML elements aftergetFirstChildElement()/XmlDocument::parse()before dereferencing. - No size validation before casting raw file bytes to
fxBank/fxProgramstructs.loadBankFromFile/loadPatchFromFile(src/plug/PluginProcessor.cpp:834-858, 930-957) now check the loaded file is at least header-sized and that the embedded chunk size fits within it before reading through the raw pointer. - Host block sizes larger than
SYNTH_MAX_BUFSIZE(8192) caused a stack buffer overflow or a permanent deadlock.processBlock(src/plug/PluginProcessor.cpp:547) now chunks rendering intoSYNTH_MAX_BUFSIZE-sized passes instead of writing the host's full block size into a fixed 8192-sample stack buffer;JaySynth::renderNextBlock(src/plug/JaySynth.cpp:977) clamps the same way and correctly holds a pending MIDI event over between passes. - Unchecked file stream could be null, crashing on plugin construction.
JaySynth::JaySynth(src/plug/JaySynth.cpp:55-57) now guards the wavetable file read againstcreateInputStream()returning null. - Parameter-changing calls raced with real-time audio rendering — no lock protected them.
JaySynth::setParameter/setControl/setPerVoiceControl/setParam/ClearControls(src/plug/JaySynth.cpp) now takeScopedLock sl(lock), matchingrenderNextBlockand the note/controller handlers. - Found while building
tools/testhost's regression suite for this list: the block-size fix above wasn't sufficient on its own.VCF_CalcCoeff_LPF/_HPF/_BPF(src/synth/vcf.c) advance the coefficient pointer once per (sample, filter-section) pair - a 4th-order filter usesFILTER_MAX_ORDER/2= 2 cascaded sections per sample - butVCF_SetBufsizeonly allocatedpCoefwithbufsizeentries, notbufsize * sections. Any host block between 4097 and 8192 samples (inclusive of the now-correctly-chunked range from the fix above) with a 4th-order filter selected wrote past the end of that buffer, corrupting adjacent heap memory (confirmed with AddressSanitizer:heap-buffer-overflow,WRITE of size 8, exactly 8 bytes past a 393216-byte/8192-vcf_coef_tallocation). Fixed by sizing the allocation for the worst case,bufsize*(FILTER_MAX_ORDER/2). Verified with ASan across the full range (4097, 8192, 16384, 20000 samples) after the fix - zero errors.
High — real-time audio-thread safety (fixed)
- Every note/CC/voice-count change allocated Strings and posted a message from the audio thread.
synthChanged()(src/plug/PluginProcessor.cpp) no longer callsJaySynthActionListener::call*directly from the audio thread. It now pushes a small POD event into a lock-free single-producer/single-consumer queue (JUCE'sAbstractFifo, added toPluginProcessor.h), and the existing 10ms UITimer(timerCallback/dispatchUiEvents) drains it on the message thread, where the String-building andsendActionMessagenow safely happen. - ~450KB–900KB of stack arrays allocated per voice per audio block.
VoiceProcessDataV's 11SYNTH_MAX_BUFSIZE-sized locals (src/synth/voice.c) are now persistent per-voice buffers (pBuf_VCO_fmout,pBuf_vco_pwm, etc., declared invoice.h), allocated once inVoiceSetBufsize/freed inVoiceFree— same lifecycle/pattern already used bypBuf_Qand byvcf.c/env.c/lfo.c. SynthDebugused unboundedvsprintfinto a fixed 1024-byte buffer.src/synth/synth_debug.cnow usesvsnprintfwith the buffer size. (Left the blockingputs()/OutputDebugStringI/O as-is — it's compiled out entirely unlessSYNTH_DEBUGis defined, so release builds are unaffected, and a lock-free logging redesign felt like overreach for a debug-only path.)
Memory management (fixed)
new[]/deletemismatches (UB) inJaySynth's destructor —m_pVoices,pPer_voice_controls,pCurrNoteInfos,humanize_voice_param[i],ppHumanizedSliders[i],m_ppAudioThread,m_ppEventAudioThreadRdy(src/plug/JaySynth.cpp) all now freed withdelete[]to match theirnew T[n]allocations. Also found and fixed the same pattern viaScopedPointerinPluginProcessor.cpp(setStateInformation/setCurrentProgramStateInformation) —ScopedPointeralways calls scalardelete(per JUCE's own doc comment), soScopedPointer<char> pUncompressedData = new char[...]was the same bug; replaced withHeapBlock<char>, JUCE's array-owning smart pointer.mallocwas never null-checked across the C DSP core. Added a sharedSynthCheckAlloc()helper (synth_defs.h/synth_debug.c) that aborts with a clear diagnostic instead of returning NULL, and wrapped all 24malloccall sites acrossenv.c,lfo.c,vcf.c,vco.c,blit.c,wavetable.c,voice.c,param_scale.c. All are one-time init/bufsize-change calls, never in the per-block render path, so this adds no real-time overhead.
Patch/Bank import-export (fixed)
- Legacy patch import decoded the wrong XML node.
patchImportXmlnow handles the legacy single-patch format (JSYNTH_PATCHroot) as its own top-level branch instead of incorrectly nested inside the modern per-child loop, which checked/decoded the outerxmlon every iteration instead of the loop variable. Also switched its CC-table import toimport_midi_cc(matching the actually-exercised convention already used bybankDecodeXml_legacy, since this branch was previously unreachable dead code).loadPatchFromFile's.xmpbranch now also accepts legacy-rooted files, so single legacy patches can actually be loaded. Also fixed a related bug found in passing:setCurrentProgramStateInformation(VST2 "copy plugin state to another track") was passingpatchImportXmlan XML node one level too deep, making it a complete no-op. - Found a much bigger bug while investigating "regular .fxb/.fxp silently ignored": the magic-number check itself was completely broken. It compared
String((const char*)&bank->chunkMagic)(raw file bytes as text, e.g."CcnK") againstString(cMagic)(JUCE'sint-to-decimal-text constructor, e.g."1130589771") — these can never be equal, so the entire opaque.fxb/.fxpload path was dead code, regardless of format. Fixed usingByteOrder::bigEndianInt, the correct byte-order-aware comparison (matches the pattern JUCE's own VST3 wrapper uses for the identical struct). "Regular" (non-chunk) format is still not supported for load, but now surfaces anAlertWindowexplaining why instead of silently doing nothing. Verified with a standalone round-trip test of the binary format logic (magic detection, size/payload round-trip, safe rejection of truncated/bogus files). .fxp/.fxbsaving was unimplemented. Now implemented using the same opaque-chunk format the (now-fixed) load path expects, reusinggetCurrentProgramStateInformation/getStateInformationfor the payload.patchDecodeXml/patchDecodeXml_legacyduplication —_legacynow just delegates topatchDecodeXml.
Design
JaySynthis a god object (src/plug/JaySynth.h) — owns parameters, MIDI CC/NRPN, MIDI clock, 3 humanize subsystems, unison, monophonic orchestration, voice stealing, and the thread pool, all via direct field access with no internal boundaries. This is very plausibly why the parameter-locking bug (now fixed) was missed in the first place — no seam enforces consistent locking discipline. Consider splitting into a handful of focused collaborators (e.g. MIDI dispatch, voice allocation, humanize/unison modes) thatJaySynthcomposes.void *the_editor(src/plug/PluginProcessor.h:394) duplicates JUCE's own editor tracking, is never cleared when the editor is destroyed, and every other call site correctly usesgetEditor(this)instead — currently harmless but a dangling-pointer trap. Fix: remove the field and routecreateEditor()throughgetEditor(this)like everywhere else.