SevyDevy

Guide · Backend

psqlODBC's October 2026 CVE Explained: When Huge IN Lists Corrupt the Heap

21 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • The psqlODBC vulnerability, CVE-2026-109448, is heap memory corruption, meaning the program's working memory gets overwritten, in the official PostgreSQL ODBC driver. It happens when an app binds more than 32767 parameters on one statement. Version 18.00.0005 fixes it.
  • ODBC is a standard way for apps to talk to many databases through a driver. A bound parameter is one value sent in place of a ? in your SQL.
  • The driver counted parameters in a signed 16-bit number, which tops out at 32,767. One more and the count turned negative, so the next bind zeroed memory before its own array.
  • To fix it, upgrade every machine to 18.00.0005 or later and restart apps that load the driver. Long value lists should travel as one array parameter, in batches, or through PostgreSQL's bulk-load command COPY in a native client.
  • Beginners can stop after the section on how the count wraps. Engineers then get the old and new source lines, exact byte counts, why pyodbc never reaches the bug, and tested version checks.

What goes wrong when an app binds too many query parameters?

With the psqlODBC driver before 18.00.0005, the app can corrupt its own memory and crash. That is the psqlODBC vulnerability, and the trigger is simply a very long list of ? placeholders in one statement.

Picture a common setup. A logistics company runs a nightly export on a Windows server. A small C++ service reads a CSV of shipment IDs and asks PostgreSQL for their details through ODBC, calling SQLBindParameter once per ID. The setup is made up, but the bug is real.

The service builds one query: SELECT * FROM shipments WHERE id IN (?, ?, ?, ...), with one ? per ID. On normal nights the CSV holds a few hundred IDs. One night a partner uploads 40,000.

Nothing in the code looks wrong. ODBC lets an app number parameters up to 65,535. Yet the driver keeps its own count in a box that holds at most 32,767. Past that point the count wraps negative, and memory outside the driver's array gets overwritten.

The official advisory says what happens next depends on the memory allocator, the code that hands out heap memory to a program. On most Linux systems, which use glibc, the standard C library, the program aborts at once. Standard Windows builds crash with an access violation. Other allocators, including the separate Windows mimalloc builds, may keep running on damaged memory and crash later.

Which words do you need before reading the psqlODBC advisory?

You need seven terms. Each one comes with the example it plays in this guide.

  • ODBC (Open Database Connectivity): a standard C interface for talking to databases. Apps call the same functions whatever the database, and a driver translates them.
  • psqlODBC: the official ODBC driver for PostgreSQL, maintained by the PostgreSQL project. On Windows its file is psqlodbc35w.dll; on Linux it is psqlodbcw.so.
  • Driver manager: the layer between your app and the driver, such as unixODBC on Linux or the built-in one on Windows. It loads the driver named in your connection.
  • Bound parameter: a value passed separately from the SQL text, in place of a ? marker. SQLBindParameter is the ODBC call that binds one, numbered from 1.
  • Signed 16-bit integer: a whole number stored in 16 bits. It holds -32,768 to 32,767. ODBC calls this type SQLSMALLINT; the unsigned version, SQLUSMALLINT, holds 0 to 65,535.
  • Heap: the pool of memory a program asks for while it runs. Writing outside the block you were given corrupts other data on the heap.
  • SQLSTATE: a five-character error code every ODBC call can return. 07009 means an invalid parameter or descriptor number, and 07002 means a count mismatch.

What is the psqlODBC vulnerability CVE-2026-109448?

CVE-2026-109448 is a heap memory corruption bug in psqlODBC, titled by the advisory "Heap memory corruption when binding more than 32767 parameters". It is rated Moderate, with a CVSS 3.1 score of 5.9.

The table shows what happens at each parameter count. The 18.00.0005 rows and the 32,767 rows were run on our own builds of both tags, against PostgreSQL 16.15. The unpatched rows past 32,767 come from the advisory and fix commit, because we do not run crash triggers.

Parameters bound on one statement, old driver vs fixed driver (as of 23 October 2026)
Parameters bound18.00.0004 and earlier18.00.0005How we know
1 to 32,767Binds and executes normally. A separate, non-memory bug could still break long casts at 1,000 or more parameters (see the 18.00.0005 section)Binds and executes normallyRun on both builds with integer parameters: 32,767 values matched 32,767 rows
Exactly 32,768Count wraps to -32,768; no corruption, but execution fails with SQLSTATE 07002Bind of parameter 32,768 rejected with SQLSTATE 07009Advisory and commit for old; run for new
32,769 or moreNext bind zero-fills memory before the arrays; heap corruptionThe bind of parameter 32,768 is refused with SQLSTATE 07009, so 32,769 is never reachedAdvisory and commit for old; run for new
Parameter number 0Not checked by the driver itselfRejected with SQLSTATE 07009Fix commit; run for new

The fixed driver does not make 40,000 parameters work. It refuses cleanly at the point where the old driver started to break. The release notes say no earlier release could run such statements, so the change should not break a working app.

In plain terms, the score says the expected harm is a crash, not stolen data. CVSS, the common scoring system for vulnerabilities, rates it as reachable over a network and hard to pull off, with no login or user action needed.

The advisory lists two weakness types. CWE-190 is integer overflow or wraparound, the counter that turned negative. CWE-124 is buffer underwrite, which means writing before the start of a block of memory rather than past its end.

How does a 16-bit parameter count wrap past 32,767?

It wraps because the largest number a signed 16-bit field can hold is 32,767. Storing 32,768 sets the sign bit, so the same 16 bits read back as -32,768.

Think of an old car's odometer with five digits. At 99,999 km, one more kilometre rolls it over to 00,000, and a buyer who trusts it thinks the car is brand new.

The mapping: the odometer is the driver's allocated field, and each kilometre is one SQLBindParameter call. The buyer who trusts the reading is the code that decides where to start clearing slots. Where the picture breaks: an odometer rolls back to zero. A signed 16-bit count rolls to -32,768, its most negative value, and the code then uses it as a position in memory.

The mismatch sits between two types. SQLBindParameter takes the parameter number as SQLUSMALLINT, so the app may legally pass up to 65,535. The driver stored its count as SQLSMALLINT, which stops at 32,767. Nothing checked the gap until 18.00.0005.

This harmless C program copies only the arithmetic of the old driver. It has no arrays and writes no memory. Pause and predict: after bind 32,768, what number will allocated hold? Write your guess down, then run it.

c
/* wrap16.c: a harmless toy. Only arithmetic and printing, no arrays. */
#include <stdio.h>
#include <stdint.h>

int main(void) {
    int16_t allocated = 0;              /* slots we think we have: signed 16-bit */
    int asks[] = {1, 1000, 32767, 32768, 32769};
    for (int i = 0; i < 5; i++) {
        int ipar = asks[i];             /* parameter number: the API allows up to 65535 */
        if (allocated < ipar) {         /* the same test the old driver used */
            long first = allocated;     /* index where zero-filling would start */
            long count = ipar - allocated;
            allocated = (int16_t) ipar; /* store into 16 bits: this is where it wraps */
            printf("bind %5d: zero-fill from index %6ld, %5ld slots; allocated is now %6d\n",
                   ipar, first, count, allocated);
        }
    }
    return 0;
}

We compiled it with gcc 13 on x86-64 Linux. This is the exact output:

text
$ gcc -Wall -o wrap16 wrap16.c && ./wrap16
bind     1: zero-fill from index      0,     1 slots; allocated is now      1
bind  1000: zero-fill from index      1,   999 slots; allocated is now   1000
bind 32767: zero-fill from index   1000, 31767 slots; allocated is now  32767
bind 32768: zero-fill from index  32767,     1 slots; allocated is now -32768
bind 32769: zero-fill from index -32768, 65537 slots; allocated is now -32767

What just happened? One extra bind pushed the count past its ceiling, and it came back as a large negative number. The program did not complain, because nothing checked.

Bind 32,768 looks innocent: it clears one slot. But it leaves the count at -32,768. Bind 32,769 then asks to clear from index -32,768, and to clear 65,537 slots. A negative index means memory before the array.

Beginners can stop here. You know what the bug is, why the count flips and that 18.00.0005 fixes it. The rest shows the exact code, the bytes it overwrote, and how to check and upgrade. If you only need to patch a machine, jump to the section on checking your driver version.

Why does a negative count write memory before the array?

Because the old code used the count as the starting index for zero-filling new slots. When the count was negative, the start address landed before the block that realloc had just returned.

Here are the relevant lines at the release tag REL-18_00_0004. The two other bookkeeping arrays, for implementation details and for data sent in pieces, follow the same pattern.

c
/* bind.c at REL-18_00_0004 (excerpt) */
apdopts = SC_get_APDF(stmt);
if (apdopts->allocated < ipar)          /* allocated is SQLSMALLINT */
    extend_parameter_bindings(apdopts, ipar);

/* inside extend_parameter_bindings(APDFields *self, int num_params) */
new_bindings = realloc(self->parameters, sizeof(ParameterInfoClass) * num_params);
pg_memset(&new_bindings[self->allocated], 0,
          sizeof(ParameterInfoClass) * (num_params - self->allocated));
self->parameters = new_bindings;
self->allocated = num_params;           /* 32768 does not fit: becomes -32768 */

Read it with the toy output in mind. On bind 32,769, realloc sizes the array for 32,769 entries, which is fine. Then pg_memset starts at entry -32,768 and zeroes 65,537 entries. Every byte before the array's start is someone else's memory.

How bind 32,769 wrote before the array in 18.00.0004

Notice that nothing fails at bind 32,768. The damage happens one call later, when the negative count becomes the start index.

Diagram connections:

  • App binds ? number N to allocated < N?
  • allocated < N? to N up to 32,767 (Yes, small N)
  • N up to 32,767 to N is 32,768 (Next bind)
  • N is 32,768 to N is 32,769 (Next bind)
  • N is 32,769 to Heap before the array (Writes zeros)
  • Heap before the array to Abort or access violation (Corrupted heap)
  • App binds ? number N: SQLBindParameter allows N up to 65535
  • allocated < N?: Compares N with a signed 16-bit count
  • N up to 32,767: Grows the arrays and stores N safely
  • N is 32,768: Count stored as -32,768; no damage yet
  • N is 32,769: memset starts at index -32,768
  • Heap before the array: Zeroed: 1.25 MiB before APD on x86-64
  • Abort or access violation: Depends on the allocator, per advisory

How many bytes get overwritten

We compiled the driver headers on x86-64 Linux and printed the struct sizes. One parameter entry in the main binding array, the APD, is 40 bytes.

So the zero-fill starts 32,768 times 40 bytes before the array: 1,310,720 bytes, or 1.25 MiB. It writes 2,621,480 bytes in all, about 2.5 MiB. That matches the fix commit's "up to 1.25 MiB", and the advisory rounds the total to "about 2.5 MB".

The other two arrays repeat the damage with smaller entries. On the same build, the IPD entry is 32 bytes and the put-data entry is 24 bytes. On a 32-bit build every size would differ, so treat these numbers as x86-64 only.

Why the bug lived for twenty years

The count was not always 16 bits. In the repository history, the fields were plain int until a commit on 11 September 2006 titled "The version is now 8.1.0102". That commit changed them to SQLSMALLINT, among type changes for 64-bit drivers.

That matches the release notes, which name 08.01.0102 as the first affected release. Few apps ever bind 32,768 values to one statement, so the wrap stayed hidden.

What did psqlODBC 18.00.0005 change?

It added one range check. SQLBindParameter now rejects any parameter number outside 1 to 32,767 with SQLSTATE 07009, before any array is grown.

c
/* bind.c at REL-18_00_0005: the new check at the top of PGAPI_BindParameter */
if (ipar < 1 || ipar > SHRT_MAX)        /* SHRT_MAX is 32767 */
{
    SC_set_error(stmt, STMT_BAD_PARAMETER_NUMBER_ERROR,
                 "Invalid parameter number for PGAPI_BindParameter.", func);
    return SQL_ERROR;                   /* the app sees SQLSTATE 07009 */
}

Why stop at 32,767 rather than widen the count? The commit gives the reason. SQLNumParams, the ODBC call that reports how many ? markers a statement has, returns SQLSMALLINT. So does the descriptor field SQL_DESC_COUNT. ODBC itself cannot describe more than 32,767 parameters.

The fix also rejects parameter number 0. The ODBC driver manager normally catches 0 first, but an app that loads the driver directly, without a manager, used to reach the driver with it.

We built 18.00.0005 from its tag and ran the project's new regression test against PostgreSQL 16.15. Below is an excerpt. The full file also has setup lines and four checks that negative descriptor counts are refused.

text
$ make installcheck TESTNAMES="param-count-limit many-params" PROVE=
ok 1 - param-count-limit
ok 2 - many-params

$ cat test/results/param-count-limit.out   (18.00.0005, excerpt)
bind 0: error SQLSTATE=07009
32767 params, bind 32767: ok
32767 params, execute: ok
32767 params, result 1
32768 params, bind 32768: error SQLSTATE=07009
32769 params, bind 32768: error SQLSTATE=07009

The release also fixed two ordinary bugs. Server-side prepared statements with 1,000 or more parameters could fail when the cast after a placeholder was long. For example, $1000::timestamp was cut to $1000::timestam, and PostgreSQL reported that type timestam does not exist. And bytea parameters sent through Microsoft's OLE DB provider for ODBC had failed since 18.00.0003.

Which apps are exposed to the psqlODBC bug?

Apps are exposed when they use psqlODBC below 18.00.0005 and can bind more than 32,767 parameters on one statement. The risk is highest when outside input decides how many values go into the query.

  • Exposed: code that builds IN (?, ?, ...) with one marker per value from an upload, an API request or a search filter, with no cap.
  • Exposed: bulk loaders that put many rows into one multi-row INSERT, with one marker per cell. Rows times columns can pass 32,767 quickly: 3,300 rows of 10 columns is 33,000.
  • Not exposed by this bug: statements with a fixed, small number of markers, however many times they run.
  • Not exposed by this bug: ODBC parameter arrays, where you bind each column once and send many rows. The parameter count equals the column count.

Many tools reach PostgreSQL through ODBC: Python's pyodbc, .NET's System.Data.Odbc classes, and reporting or ETL tools that ask you to pick a driver. The flaw is in the driver, but whether an app can reach it depends on its library. A library that binds every value without first asking how many markers the statement has can reach it.

We read this in pyodbc's params.cpp and checked it without binding anything. On both 18.00.0004 and 18.00.0005, a statement with 40,000 markers reported -25,536 from SQLNumParams, and one with 65,537 reported 1.

On 18.00.0005, pyodbc then raised its own error: The SQL contains -25536 parameter markers, but 40000 parameters were supplied. Note that the fix guards binding, not counting, so SQLNumParams still wraps in 18.00.0005.

That protects pyodbc users, not everyone. Another library, or C or C++ code like the export service, may call SQLBindParameter without asking SQLNumParams first. Upgrade the driver regardless.

How do you check your psqlODBC driver version on Linux and Windows?

Ask the driver itself through the ODBC call SQLGetInfo with SQL_DRIVER_VER, or read the version from the package or the file. You want 18.00.0005 or later. The nightly export runs on a Windows server, so start there.

On Windows

  1. Open the right ODBC administrator

    64-bit Windows has two. The 64-bit one is in C:\Windows\System32 and opens from Control Panel. The 32-bit one is odbcad32.exe in C:\Windows\SysWOW64 and serves 32-bit apps.

  2. Read the Drivers tab

    Find PostgreSQL Unicode(x64) and PostgreSQL ANSI(x64), or the entries without (x64). The Version column shows the driver file's internal version number, which should be 18.00.0005 or later.

  3. Repeat in the other administrator

    If you run any 32-bit app, such as an older reporting tool, check the 32-bit list too. Each bitness has its own installed driver.

  4. Confirm from code if you can

    SQLGetInfo with SQL_DRIVER_VER returns the exact version string; from pyodbc it is conn.getinfo(pyodbc.SQL_DRIVER_VER). In our runs it printed 18.00.0004 and 18.00.0005 for the two builds.

We could not run the Drivers tab on Windows. As of 23 October 2026 we have not confirmed whether it prints the version as 18.00.0005 or in another dotted form, so trust SQL_DRIVER_VER for the exact string.

On Linux

We ran each of these on Ubuntu 24.04.5 with unixODBC 2.3.12. Replace USERNAME, PASSWORD and the connection details with your own. Check 4 works for any version, so treat it as the main check.

bash
# 1. Which PostgreSQL drivers are registered, and which file do they load?
odbcinst -q -d
grep -A3 "PostgreSQL Unicode" /etc/odbcinst.ini

# 2. Package version (Debian/Ubuntu; on RPM systems try: rpm -qa | grep -i odbc)
dpkg -s odbc-postgresql | grep Version

# 3. Version string inside the driver file (Debian/Ubuntu path; matches 10.x and later only)
strings -a /usr/lib/x86_64-linux-gnu/odbc/psqlodbcw.so | grep -xE '1[0-9]\.[0-9]{2}\.[0-9]{4}'

# 4. Ask the driver at runtime (pyodbc)
python3 -c "import pyodbc; c = pyodbc.connect('Driver={PostgreSQL Unicode};Servername=localhost;Database=postgres;Username=USERNAME;Password=PASSWORD'); print(c.getinfo(pyodbc.SQL_DRIVER_VER))"

Expected output looks like this. The dpkg line is the Ubuntu package. The strings lines come from the same command run on three files: the Ubuntu package and our builds of the old and fixed tags.

text
$ dpkg -s odbc-postgresql | grep Version
Version: 1:16.00.0000-1
$ strings -a /usr/lib/x86_64-linux-gnu/odbc/psqlodbcw.so | grep -xE '1[0-9]\.[0-9]{2}\.[0-9]{4}'
16.00.0000
# the same strings command on our builds of the two tags:
18.00.0004
18.00.0005

The strings pattern only matches 10.x and later, because every build also contains an unrelated 09.00.1399 string. If it prints nothing, use check 4. The file path is the Debian and Ubuntu one; on RPM systems, rpm -ql on the package lists it.

Now look at the package version. As of 23 October 2026, Ubuntu 24.04's odbc-postgresql package is 1:16.00.0000-1, inside the affected range by version number. Ubuntu's CVE tracker lists the psqlodbc source package as Needs evaluation for 24.04 and every other listed release.

Debian's tracker, on the same date, marks bookworm (1:13.02.0000-2) and trixie (1:17.00.0004-2) as vulnerable. It marks 1:18.00.0005-1 in forky and sid as fixed. Recheck your distribution's tracker before you decide.

How do you upgrade to psqlODBC 18.00.0005?

Install the 18.00.0005 package for each platform and bitness you use, then restart every long-running process that loads the driver. A running process keeps the old driver code in memory until it restarts.

  1. List every machine that loads the driver

    Include app servers, ETL hosts, report servers and analysts' laptops. Use the checks above to record the version on each one.

  2. Windows: install the matching MSI

    The postgresql.org download folder for REL-18_00_0005 has psqlodbc_x64.msi, psqlodbc_x86.msi and psqlodbc_arm64.msi, plus psqlodbc-setup.exe. Install x86 as well if 32-bit apps use the driver.

  3. Linux: use your distribution's update, or build the tag

    When the package update is not out yet, build the REL-18_00_0005 tag from the GitHub repository. You need libpq and unixODBC development packages.

  4. Restart apps and services

    Restart web workers, schedulers and desktop tools that hold an ODBC connection, so they load the new library file.

  5. Verify

    Run the version check again. SQL_DRIVER_VER should print 18.00.0005 or later on every machine.

We built both tags on Ubuntu 24.04 with autoreconf -i, then configure and make. One snag: Ubuntu's unixodbc-dev has no odbc_config helper, so configure stops. Passing --with-unixodbc=__without_odbc_config, a value configure.ac checks for, lets the build use the default include paths.

What are safer patterns than a 40,000-item IN list?

Send long lists as one array parameter, split them into batches, use ODBC parameter arrays for bulk inserts, or load big files with COPY through a native PostgreSQL client. Each keeps the parameter count small whatever the input size.

Picking a pattern for a large list of values

Every branch keeps the number of ? markers far below 32,767, so the driver's limit never matters.

Diagram connections:

  • List of values from input to Lookup or bulk load?
  • Lookup or bulk load? to = ANY(CAST(? AS int[])) (Lookup)
  • Lookup or bulk load? to Batches of 1,000 (Lookup, no array type)
  • Lookup or bulk load? to ODBC parameter arrays (Bulk insert)
  • Lookup or bulk load? to COPY via native client (Huge file load)
  • = ANY(CAST(? AS int[])) to PostgreSQL
  • Batches of 1,000 to PostgreSQL
  • ODBC parameter arrays to PostgreSQL
  • COPY via native client to PostgreSQL
  • List of values from input: IDs from a CSV, an API call or a filter
  • Lookup or bulk load?: Decide what the statement does
  • = ANY(CAST(? AS int[])): One parameter carries the whole list
  • Batches of 1,000: Many short statements, same result
  • ODBC parameter arrays: Markers equal columns; rows sent together
  • COPY via native client: psql or libpq; not through psqlODBC
  • PostgreSQL: Receives a small, fixed parameter count

We ran this script with pyodbc 5.3.0 against our 18.00.0005 build and PostgreSQL 16.15. It looks up 100,000 IDs, well past the limit, without ever binding more than 1,000 parameters.

python
import pyodbc

conn = pyodbc.connect("Driver={PostgreSQL Unicode};Servername=localhost;"
                      "Database=postgres;Username=USERNAME;Password=PASSWORD", autocommit=True)
print("driver version:", conn.getinfo(pyodbc.SQL_DRIVER_VER))
cur = conn.cursor()
ids = list(range(1, 100_001))          # 100,000 ids: far past 32,767

# 1. One array parameter instead of 100,000 placeholders
arr = "{" + ",".join(map(str, ids)) + "}"
cur.execute("SELECT count(*) FROM generate_series(1, 200000) g "
            "WHERE g = ANY(CAST(? AS int[]))", arr)
print("ANY(array), 1 parameter, matched:", cur.fetchone()[0])

# 2. Batching: chunks of 1,000 placeholders
total = 0
for i in range(0, len(ids), 1000):
    chunk = ids[i:i + 1000]
    marks = ",".join("?" * len(chunk))
    cur.execute(f"SELECT count(*) FROM generate_series(1, 200000) g WHERE g IN ({marks})", *chunk)
    total += cur.fetchone()[0]
print("batches of 1,000, matched:", total)

# 3. Bulk insert with parameter arrays: 2 parameters per statement
cur.execute("DROP TABLE IF EXISTS t_bulk")
cur.execute("CREATE TABLE t_bulk (id int, name text)")
cur.fast_executemany = True
cur.executemany("INSERT INTO t_bulk VALUES (?, ?)", [(i, f"name{i}") for i in range(50_000)])
cur.execute("SELECT count(*) FROM t_bulk")
print("executemany with parameter arrays, rows inserted:", cur.fetchone()[0])

# 4. COPY FROM STDIN through ODBC
try:
    cur.execute("COPY t_bulk FROM STDIN")
except pyodbc.Error as e:
    print("COPY FROM STDIN:", e.args[0])

And the output:

text
driver version: 18.00.0005
ANY(array), 1 parameter, matched: 100000
batches of 1,000, matched: 100000
executemany with parameter arrays, rows inserted: 50000
COPY FROM STDIN: HYC00

For the nightly export, that means one = ANY(CAST(? AS bigint[])) parameter carrying all 40,000 shipment IDs. We ran that shape too, and it matched all 40,000.

What each pattern costs

ANY with an array sends one statement and one parameter. The cast in the SQL tells PostgreSQL to read the text as an integer array, so the values still never mix with SQL text. This is the advisory's own suggestion.

Batching is the most portable choice, because it works on any database. The cost is one round trip per batch: 100 statements for 100,000 IDs in our run. Pick a batch size you have tested, and add the results in code.

Parameter arrays suit bulk inserts. In pyodbc, fast_executemany turns them on, and our 50,000-row insert used two markers per statement. Other ODBC clients set SQL_ATTR_PARAMSET_SIZE for the same effect.

COPY is PostgreSQL's fastest way to load data, but psqlODBC does not support COPY FROM STDIN. Our attempt failed with SQLSTATE HYC00, meaning an optional feature is not implemented. The driver's message reads: COPY ... FROM STDIN / TO STDOUT is not supported in ODBC. Use psql's \copy, or a libpq-based client, for that step.

How do you explain the psqlODBC vulnerability in a system design interview?

Treat it as a story about type limits at an API boundary, then show how you would design around it.

  1. State the bug in one line

    The driver stored the bound-parameter count in a signed 16-bit field, so binding more than 32,767 parameters wrapped it negative and corrupted the heap.

  2. Name the boundary mismatch

    The ODBC call allows parameter numbers up to 65,535, but the driver's storage and ODBC's own counts stop at 32,767. Inputs crossed a limit nobody checked.

  3. Explain the mechanism

    The negative count became the start index for a memset, which wrote zeros before the array. Say buffer underwrite, CWE-124.

  4. Give the fix and why it is enough

    Version 18.00.0005 rejects parameter numbers outside 1 to 32,767 with SQLSTATE 07009. ODBC cannot describe more parameters anyway, so nothing that worked breaks.

  5. Show the design lesson

    Never let outside input set the size of a query's shape. Use one array parameter, batches or bulk-load paths, and cap counts in your own code.

  6. Close with operations

    Inventory drivers on every host and bitness, upgrade, restart processes, and verify with SQL_DRIVER_VER. Watch distribution packages that lag behind upstream.

If you are asked for a number, use the ones from the source: 32,767 parameters, and on x86-64 about 1.25 MiB zeroed before the main binding array.

Questions people ask

Which psqlODBC version fixes CVE-2026-109448?
Version 18.00.0005 fixes it, released on 12 October 2026 and announced on 14 October. The advisory lists 18.00.0004 and earlier as affected, and the release notes name 08.01.0102 through 18.00.0004. Any later release should include the fix, but check its release notes to be sure.
Is the threshold 32,767 or 32,768 parameters?
The advisory says binding more than 32767 parameters is the problem. At exactly 32,768 the count wraps but memory is not yet corrupted; execution fails with SQLSTATE 07002. Corruption starts when parameter 32,769 is bound. The fixed driver refuses anything above 32,767.
Can an attacker steal data through this bug?
The advisory does not say so. Its CVSS vector scores no confidentiality or integrity impact, only availability, so the expected harm is a crash or an unstable process. An attacker would need a way to control how many parameters your app binds, such as an uncapped IN list built from their input.
Does the fix let me bind more than 32,767 parameters?
No. The fix makes the driver refuse parameter numbers above 32,767 with SQLSTATE 07009. ODBC's own SQLNumParams and SQL_DESC_COUNT use a signed 16-bit type, so the standard cannot describe more. Use one array parameter, batches or a bulk-load path for longer lists.
Do I need to restart anything after upgrading the driver?
Yes. The driver is a library loaded into each app's process, so a running process keeps the old code until it restarts. Restart web workers, schedulers, report servers and desktop tools that use ODBC, then confirm SQL_DRIVER_VER shows 18.00.0005 or later.
Is this the same bug as the PostgreSQL JDBC advisories from October 2026?
No. pgjdbc is the Java driver, and its October 2026 advisories are separate issues with their own fixes and versions. This bug is in psqlODBC, the ODBC driver, and is about a 16-bit parameter count. Java apps that use pgjdbc do not load psqlODBC.
Why does my Linux package still say 16.00.0000?
Distribution packages often lag behind upstream releases. As of 23 October 2026, Ubuntu 24.04 offers odbc-postgresql 1:16.00.0000-1, inside the affected range, and Ubuntu's tracker still says Needs evaluation. Debian has 1:18.00.0005-1 only in forky and sid. Recheck the tracker, or build the REL-18_00_0005 tag yourself.

Sources and further reading

Practise this with Lala

Practise