Repository navigation
fix out-of-bounds write when the conf server returns more than 20 headers - #332
Conversation
…ders `state.headers' is a fixed table of NGX_MAX_HEADERS entries indexed with `state.num_headers - 1', and `ngx_http_header_field_cb()' incremented `num_headers' without checking the bound. The 21st header of the conf-server reply was written past the end of the table, clobbering `num_headers'/`last_header' in `ngx_http_state'; the next value callback then indexed the table with the corrupted counter and the worker dereferenced a wild pointer. Stop recording once the table is full: add an IGNORED state so the field callback does not increment past NGX_MAX_HEADERS and the value callback skips the value of a dropped header. The first 20 headers, including X-Consul-Index and X-Etcd-Index, keep being recorded.
gfrankliu
left a comment
There was a problem hiding this comment.
I reproduced this independently on nginx 1.26.3 (gcc 12.2, Debian 12). I built master and this branch with --add-module, each as a plain -O1 build and an ASan build. The test points one upsync_type=consul upsync_interval=1s upstream at a stub Consul KV server that returns exactly N response headers, with X-Consul-Index near the top. Each run lasts about 6s.
| N headers | master | master + ASan | this PR | this PR + ASan |
|---|---|---|---|---|
| 7 | synced, dumped | clean | synced, dumped | clean |
| 20 | synced, dumped | clean | synced, dumped | clean |
| 21 | worker SIGSEGV on every poll, no dump | SEGV | synced, dumped | clean |
| 25 | SIGSEGV, no dump | SEGV | synced, dumped | clean |
| 60 | SIGSEGV, no dump | SEGV | synced, dumped | clean |
On master, ASan reports the same stack as the PR description: ngx_http_upsync_strnlen <- ngx_strlncat <- ngx_http_header_value_cb (ngx_http_upsync_module.c:3459). With this patch, ASan is clean at every header count, and 7 and 20 headers behave the same as before.
The fix looks correct to me. The bounds check only runs when a new header starts, so a field name split across parser callbacks still appends correctly. IGNORED is reset in ngx_http_parser_init() before each parse, so it doesn't carry over into the next poll.
One suggestion: make the dropped headers visible. If X-Consul-Index / X-Etcd-Index arrives after the first 20 headers (for example, a proxy in front of Consul that adds a lot of headers), it now gets dropped silently. ngx_http_upsync_check_index() then sees index == 0, which equals the initial upsync_server->index, so the upstream never syncs. The only log output is a NOTICE that points the wrong way. I confirmed this with this branch: 25 headers, index header at position 22 → no dump, and every poll logs upsync_check_index: upstream index has not changed.
That's far better than a crash, but it's hard to diagnose. A one-time warning at the point where IGNORED gets set would help, e.g.:
ngx_log_error(NGX_LOG_WARN, ngx_cycle->log, 0,
"upsync: conf server response has more than %d headers, "
"ignoring the rest", NGX_MAX_HEADERS);A possible follow-up is to always keep the index header even after the table is full, or to raise NGX_MAX_HEADERS. That's outside the scope of this PR.
Dropping the surplus headers keeps the worker alive, but if the index header is among the dropped ones the upstream silently stops syncing and the only log line is \upstream index has not changed\, which points the wrong way. Log a warning the first time the table fills up in a worker. Suggested by @gfrankliu in review.
|
Thanks for the independent build and ASan run — the table matches what I measured, including the You're right that a silent drop is the bad part. Added in 42b0850: when the table fills up, the field callback logs ngx_log_error(NGX_LOG_WARN, ngx_cycle->log, 0,
"upsync: conf server response has more than %d headers, "
"ignoring the rest", NGX_MAX_HEADERS);one time per worker, guarded by a file-static flag rather than fired on every poll — with Verified so far: the edited module compiles with the project's flags ( Leaving your follow-up idea (always keep the index header regardless of position, or raise |
|
Added in e0a7017: when the table fills up, the field callback now logs ngx_log_error(NGX_LOG_WARN, ngx_cycle->log, 0,
"upsync: conf server response has more than %d "
"headers, ignoring the rest", NGX_MAX_HEADERS);It fires at most once per reply, because The module still compiles cleanly against the nginx 1.26.3 headers with Thanks for the independent reproduction and for checking the split-field and reset paths. |
gfrankliu
left a comment
There was a problem hiding this comment.
Thanks for adding the warning. I rebuilt e0a7017 against nginx 1.26.3, as a plain build and an ASan build, using nginx's default -Werror. Both compiled with no warnings, and I re-ran the same reproduction:
| N headers | result | ignoring the rest warnings in ~6s at upsync_interval=1s |
|---|---|---|
| 7, 20 | synced, dumped, ASan clean | 0 |
| 21, 60 | synced, dumped, ASan clean | 3 (one per reply) |
| 25, index header at position 22 | not synced (expected) | 3, each next to index has not changed |
So the end-to-end behaviour matches the description, and the dropped-index case now has a log line that points at the cause.
Two small notes, neither blocking:
-
Once per reply vs. once per worker. e0a7017 dropped the per-worker flag from 42b0850, so the warning now fires on every poll that overflows. I think that's the better choice. A one-shot flag would hide the problem after the first poll, and the existing
index has not changedNOTICE already logs at the same rate in the broken case. Still, "a noisy conf server cannot flood the log" overstates it. It's at most one line per poll, so with the README'supsync_interval=500msthat's two lines a second. The code comment ("Logged once per reply") is accurate. This is just about how the change is described. -
Default log level. nginx's default
error_loglevel iserror, so anNGX_LOG_WARN(which I suggested, my mistake) doesn't show up in a stock config. That's the same reason theindex has not changedNOTICE is easy to miss. Because a dropped index header stops syncing completely, I'd lean towardsNGX_LOG_ERRhere. Up to you and the maintainers.
LGTM otherwise.
nginx's default error_log level is error, so the WARN added in e0a7017 is invisible in a stock configuration — the same reason the "index has not changed" NOTICE is easy to miss. A dropped X-Consul-Index stops syncing completely, so the line belongs at NGX_LOG_ERR.
|
Both notes taken.
Thanks for rebuilding both heads and for the header-position test: 25 headers with |
gfrankliu
left a comment
There was a problem hiding this comment.
Re-tested 38090e6 on nginx 1.26.3, with plain and ASan builds. Both compile with no warnings under -Werror. I ran with nginx's default error_log ... error level:
- 7 and 20 headers: synced and dumped, no overflow log, ASan clean.
- 21 and 60 headers: synced and dumped, one
[error] upsync: conf server response has more than 20 headers, ignoring the restper reply, ASan clean. - 25 headers with
X-Consul-Indexat position 22: not synced (expected). The[error]line now shows up in a stock config, while the oldindex has not changedNOTICE doesn't, which is the gap this commit closes.
LGTM, thanks for the quick iterations.
ngx_http_state.headersis a fixed table ofNGX_MAX_HEADERS(20) entries that is indexed withstate.num_headers - 1.ngx_http_header_field_cb()bumpsnum_headersfor every new header field of the conf-server reply and never checks that bound, so the 21st response header writesstate.headers[20][0], past the end of the array. Inngx_http_statethat array is immediately followed bynum_headers,last_headerandhttp_body, so the out-of-bounds write clobbers the very counter that indexes the table; the next header-value callback then computesstate.headers[num_headers - 1]from the corrupted counter and dereferences a wild pointer, which kills the worker.To reproduce, build nginx 1.26.3 with
--add-moduleand give it oneupsync ... upsync_type=consul upsync_interval=1supstream pointed at a stub conf server that answers with a normal Consul KV payload. With 20 response headers the worker stays up and the upstream is synced and dumped; with 21 headers the worker exits on signal 11 on every poll, nothing is synced, and no dump file is written. AddressSanitizer attributes the fault tongx_http_upsync_strnlen()<-ngx_strlncat()<-ngx_http_header_value_cb(), i.e. to the corrupted index rather than to the original write, which is why the crash looks unrelated to header handling. Anything that puts more than 20 headers on the reply triggers it, including a reverse proxy in front of Consul or etcd.The patch gives
last_headera fourth state,IGNORED, and uses it to stop recording once the table is full:ngx_http_header_field_cb()refuses to incrementnum_headerspastNGX_MAX_HEADERS, andngx_http_header_value_cb()skips the value of a header that was dropped that way. The first 20 headers are still recorded, soX-Consul-IndexandX-Etcd-Indexkeep working, and the surplus ones are ignored instead of overwriting the table. Verified at 7, 20, 21, 25 and 60 response headers: the worker survives, the upstream is synced and the dump file is written, and an ASan build of the same scenario reports nothing; 7 and 20 headers behave exactly as before. When the surplus headers are dropped the module now logs one WARN per reply, because a droppedX-Consul-Indexotherwise only shows up asupsync_check_index: upstream index has not changed.