http2: assertion failure onread->IsFunction() when a session is destroyed from a 'stream' handler #64850

Description

@mcollina

What steps will reproduce the bug?

node:http2 aborts on an internal assertion when an HTTP/2 session is destroyed from inside a 'stream' handler while the peer's DATA frames are still in the receive buffer:

node[...]: v8::MaybeLocal<v8::Value> node::StreamBase::CallJSOnreadMethod(ssize_t, v8::Local<v8::ArrayBuffer>, std::size_t, StreamBaseJSChecks) at ../src/stream_base.cc:473
Assertion failed: onread->IsFunction()
1: node::Assert(node::AssertionInfo const&)
2: node::StreamBase::CallJSOnreadMethod(...)
3: node::http2::Http2StreamListener::OnStreamRead(long, uv_buf_t const&)
4: node::http2::Http2Session::OnDataChunkReceived(nghttp2_session*, unsigned char, int, unsigned char const*, unsigned long, void*)
5: nghttp2_session_mem_recv2
6: node::http2::Http2Session::ConsumeHTTP2Data()
7: node::http2::Http2Session::OnStreamRead(long, uv_buf_t const&)

handle.onread is assigned in exactly one place — Http2Stream[kInit] in lib/internal/http2/core.js — so nghttp2 is delivering a DATA chunk to a stream whose JS wrapper has already been torn down.

This is reachable from ordinary peer behaviour: the server simply destroys its own session from the 'stream' handler, which runs inside ConsumeHTTP2Data().

Minimal reproduction

No dependencies, no TLS. Aborts within a few hundred rounds, typically well under the 5s default window:

'use strict'// Standalone reproduction — node:http2 only, no dependencies.//// An HTTP/2 server destroys its own session from inside the 'stream' handler,// which nghttp2 dispatches from within Http2Session::ConsumeHTTP2Data. The// client sends several concurrent POSTs whose HEADERS and DATA arrive in the// same receive buffer, so nghttp2 keeps delivering DATA for the remaining// streams after JS has torn the session down. Delivery then reaches a stream// whose JS wrapper is gone://// node::StreamBase::CallJSOnreadMethod at ../src/stream_base.cc:473// Assertion failed: onread->IsFunction()// <- node::http2::Http2StreamListener::OnStreamRead// <- node::http2::Http2Session::OnDataChunkReceived// <- nghttp2_session_mem_recv2// <- node::http2::Http2Session::ConsumeHTTP2Data//// handle.onread is assigned in exactly one place, Http2Stream[kInit]// (lib/internal/http2/core.js), so the C++ side is calling into a stream that// was destroyed while the receive operation was still in flight.//// Usage:// node repro-h2-abort.js//// Exit status 134 (SIGABRT) means it reproduced; 0 means it survived the run.// Reproduces over cleartext h2 (and equally over TLS), and does not depend on// --expose-gc.consthttp2=require('node:http2')constDURATION=Number(process.env.DURATION||5000)constSTREAMS=Number(process.env.STREAMS||8)// Small enough that HEADERS and DATA coalesce into a single read.constBODY=Buffer.alloc(Number(process.env.BODY||2048),'a')constserverOptions={settings: {maxConcurrentStreams: Number(process.env.MAX_CONCURRENT_STREAMS||4)}}constserver=http2.createServer(serverOptions)server.on('session',(session)=>session.on('error',()=>{}))// The whole trigger: tear the connection down from inside the stream handler,// i.e. while nghttp2 is still walking the buffer that delivered this stream.server.on('stream',(stream)=>{stream.on('error',()=>{})stream.session.destroy()})letrounds=0server.listen(0,'127.0.0.1',()=>{constport=server.address().portconstorigin=`http://127.0.0.1:${port}`letstopped=falseconstround=()=>{if(stopped)returnrounds++constsession=http2.connect(origin)session.on('error',()=>{})session.on('close',()=>setImmediate(round))session.on('connect',()=>{for(leti=0;i<STREAMS;i++){conststream=session.request({':path': `/${i}`,':method': 'POST'})stream.on('error',()=>{})stream.resume()stream.end(BODY)}})}round()setTimeout(()=>{stopped=trueconsole.log(`survived ${rounds} rounds without aborting`)server.close()process.exit(0)},DURATION)})
$ node repro-h2-abort.jsAssertion failed: onread->IsFunction()Aborted (core dumped)
$ echo$?134

Does not depend on --expose-gc (4/4 with and without). Reproduces over TLS as well; the TLSWrap::ClearOut frame in the original stack was incidental.

Affected versions

buildresult (3 runs each)
main @ 012ecf51d39, clean checkout3/3 abort
v24.18.0 (release binary)0/3 — ~6,000 rounds clean
v22.22.3 (release binary)0/3 — ~6,000 rounds clean

Bisect

Introduced by 46de80dhttp2: avoid uaf while receiving and sending rst_stream (#64166, fixing #64113):

46de80de88c BAD (rc=134)
46de80de88c~1 GOOD

That commit defers session close while nghttp2_session_mem_recv() is in progress:

if (is_receiving()) {
set_close_pending();
pending_close_code_ = code;
pending_close_socket_closed_ = socket_closed;
return; // finished later via MaybeFinishPendingClose()
}

Our reproduction destroys the session from inside a 'stream' handler, i.e. while receiving. Before that change the close completed immediately and the torn-down streams stopped receiving data. With the close deferred, nghttp2 keeps walking the same receive buffer and delivers DATA to streams whose JS wrappers have already been destroyed, so CallJSOnreadMethod finds onread unset.

So the fix for one use-after-free in the mem_recv window appears to have opened a different lifetime hole in the same window. Note this is not addressed by ba6cb5c34c2 (http2: defer rst stream while in scope) — I cherry-picked that on top and it still aborts 5/5.

Discovery

Found via undici's HTTP/2 test suite, where it surfaced under borp as a bare 'test failed' with one subtest silently missing and no assertion text — worth knowing, since that signature is easy to mistake for flakiness. Running the test file directly is what exposes exit 134.

cc @Eusgor@mcollina@pimterry@RafaelGSS

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
     blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
    }
    } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
    })();
    (function(){
    try {
    var __m = "github.com";
    var __re = new RegExp('^' + "github\\.com" + '
    
    Skip to content

    http2: assertion failure onread->IsFunction() when a session is destroyed from a 'stream' handler #64850

    Description

    @mcollina

    What steps will reproduce the bug?

    node:http2 aborts on an internal assertion when an HTTP/2 session is destroyed from inside a 'stream' handler while the peer's DATA frames are still in the receive buffer:

    node[...]: v8::MaybeLocal<v8::Value> node::StreamBase::CallJSOnreadMethod(ssize_t, v8::Local<v8::ArrayBuffer>, std::size_t, StreamBaseJSChecks) at ../src/stream_base.cc:473
    Assertion failed: onread->IsFunction()
    1: node::Assert(node::AssertionInfo const&)
    2: node::StreamBase::CallJSOnreadMethod(...)
    3: node::http2::Http2StreamListener::OnStreamRead(long, uv_buf_t const&)
    4: node::http2::Http2Session::OnDataChunkReceived(nghttp2_session*, unsigned char, int, unsigned char const*, unsigned long, void*)
    5: nghttp2_session_mem_recv2
    6: node::http2::Http2Session::ConsumeHTTP2Data()
    7: node::http2::Http2Session::OnStreamRead(long, uv_buf_t const&)
    

    handle.onread is assigned in exactly one place — Http2Stream[kInit] in lib/internal/http2/core.js — so nghttp2 is delivering a DATA chunk to a stream whose JS wrapper has already been torn down.

    This is reachable from ordinary peer behaviour: the server simply destroys its own session from the 'stream' handler, which runs inside ConsumeHTTP2Data().

    Minimal reproduction

    No dependencies, no TLS. Aborts within a few hundred rounds, typically well under the 5s default window:

    'use strict'// Standalone reproduction — node:http2 only, no dependencies.//// An HTTP/2 server destroys its own session from inside the 'stream' handler,// which nghttp2 dispatches from within Http2Session::ConsumeHTTP2Data. The// client sends several concurrent POSTs whose HEADERS and DATA arrive in the// same receive buffer, so nghttp2 keeps delivering DATA for the remaining// streams after JS has torn the session down. Delivery then reaches a stream// whose JS wrapper is gone://// node::StreamBase::CallJSOnreadMethod at ../src/stream_base.cc:473// Assertion failed: onread->IsFunction()// <- node::http2::Http2StreamListener::OnStreamRead// <- node::http2::Http2Session::OnDataChunkReceived// <- nghttp2_session_mem_recv2// <- node::http2::Http2Session::ConsumeHTTP2Data//// handle.onread is assigned in exactly one place, Http2Stream[kInit]// (lib/internal/http2/core.js), so the C++ side is calling into a stream that// was destroyed while the receive operation was still in flight.//// Usage:// node repro-h2-abort.js//// Exit status 134 (SIGABRT) means it reproduced; 0 means it survived the run.// Reproduces over cleartext h2 (and equally over TLS), and does not depend on// --expose-gc.consthttp2=require('node:http2')constDURATION=Number(process.env.DURATION||5000)constSTREAMS=Number(process.env.STREAMS||8)// Small enough that HEADERS and DATA coalesce into a single read.constBODY=Buffer.alloc(Number(process.env.BODY||2048),'a')constserverOptions={settings: {maxConcurrentStreams: Number(process.env.MAX_CONCURRENT_STREAMS||4)}}constserver=http2.createServer(serverOptions)server.on('session',(session)=>session.on('error',()=>{}))// The whole trigger: tear the connection down from inside the stream handler,// i.e. while nghttp2 is still walking the buffer that delivered this stream.server.on('stream',(stream)=>{stream.on('error',()=>{})stream.session.destroy()})letrounds=0server.listen(0,'127.0.0.1',()=>{constport=server.address().portconstorigin=`http://127.0.0.1:${port}`letstopped=falseconstround=()=>{if(stopped)returnrounds++constsession=http2.connect(origin)session.on('error',()=>{})session.on('close',()=>setImmediate(round))session.on('connect',()=>{for(leti=0;i<STREAMS;i++){conststream=session.request({':path': `/${i}`,':method': 'POST'})stream.on('error',()=>{})stream.resume()stream.end(BODY)}})}round()setTimeout(()=>{stopped=trueconsole.log(`survived ${rounds} rounds without aborting`)server.close()process.exit(0)},DURATION)})
    $ node repro-h2-abort.jsAssertion failed: onread->IsFunction()Aborted (core dumped)
    $ echo$?134

    Does not depend on --expose-gc (4/4 with and without). Reproduces over TLS as well; the TLSWrap::ClearOut frame in the original stack was incidental.

    Affected versions

    buildresult (3 runs each)
    main @ 012ecf51d39, clean checkout3/3 abort
    v24.18.0 (release binary)0/3 — ~6,000 rounds clean
    v22.22.3 (release binary)0/3 — ~6,000 rounds clean

    Bisect

    Introduced by 46de80dhttp2: avoid uaf while receiving and sending rst_stream (#64166, fixing #64113):

    46de80de88c BAD (rc=134)
    46de80de88c~1 GOOD
    

    That commit defers session close while nghttp2_session_mem_recv() is in progress:

    if (is_receiving()) {
    set_close_pending();
    pending_close_code_ = code;
    pending_close_socket_closed_ = socket_closed;
    return; // finished later via MaybeFinishPendingClose()
    }

    Our reproduction destroys the session from inside a 'stream' handler, i.e. while receiving. Before that change the close completed immediately and the torn-down streams stopped receiving data. With the close deferred, nghttp2 keeps walking the same receive buffer and delivers DATA to streams whose JS wrappers have already been destroyed, so CallJSOnreadMethod finds onread unset.

    So the fix for one use-after-free in the mem_recv window appears to have opened a different lifetime hole in the same window. Note this is not addressed by ba6cb5c34c2 (http2: defer rst stream while in scope) — I cherry-picked that on top and it still aborts 5/5.

    Discovery

    Found via undici's HTTP/2 test suite, where it surfaced under borp as a bare 'test failed' with one subtest silently missing and no assertion text — worth knowing, since that signature is easy to mistake for flakiness. Running the test file directly is what exposes exit 134.

    cc @Eusgor@mcollina@pimterry@RafaelGSS

    Metadata

    Metadata

    Assignees

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
      Skip to content

      http2: assertion failure onread->IsFunction() when a session is destroyed from a 'stream' handler #64850

      Description

      @mcollina

      What steps will reproduce the bug?

      node:http2 aborts on an internal assertion when an HTTP/2 session is destroyed from inside a 'stream' handler while the peer's DATA frames are still in the receive buffer:

      node[...]: v8::MaybeLocal<v8::Value> node::StreamBase::CallJSOnreadMethod(ssize_t, v8::Local<v8::ArrayBuffer>, std::size_t, StreamBaseJSChecks) at ../src/stream_base.cc:473
      Assertion failed: onread->IsFunction()
      1: node::Assert(node::AssertionInfo const&)
      2: node::StreamBase::CallJSOnreadMethod(...)
      3: node::http2::Http2StreamListener::OnStreamRead(long, uv_buf_t const&)
      4: node::http2::Http2Session::OnDataChunkReceived(nghttp2_session*, unsigned char, int, unsigned char const*, unsigned long, void*)
      5: nghttp2_session_mem_recv2
      6: node::http2::Http2Session::ConsumeHTTP2Data()
      7: node::http2::Http2Session::OnStreamRead(long, uv_buf_t const&)
      

      handle.onread is assigned in exactly one place — Http2Stream[kInit] in lib/internal/http2/core.js — so nghttp2 is delivering a DATA chunk to a stream whose JS wrapper has already been torn down.

      This is reachable from ordinary peer behaviour: the server simply destroys its own session from the 'stream' handler, which runs inside ConsumeHTTP2Data().

      Minimal reproduction

      No dependencies, no TLS. Aborts within a few hundred rounds, typically well under the 5s default window:

      'use strict'// Standalone reproduction — node:http2 only, no dependencies.//// An HTTP/2 server destroys its own session from inside the 'stream' handler,// which nghttp2 dispatches from within Http2Session::ConsumeHTTP2Data. The// client sends several concurrent POSTs whose HEADERS and DATA arrive in the// same receive buffer, so nghttp2 keeps delivering DATA for the remaining// streams after JS has torn the session down. Delivery then reaches a stream// whose JS wrapper is gone://// node::StreamBase::CallJSOnreadMethod at ../src/stream_base.cc:473// Assertion failed: onread->IsFunction()// <- node::http2::Http2StreamListener::OnStreamRead// <- node::http2::Http2Session::OnDataChunkReceived// <- nghttp2_session_mem_recv2// <- node::http2::Http2Session::ConsumeHTTP2Data//// handle.onread is assigned in exactly one place, Http2Stream[kInit]// (lib/internal/http2/core.js), so the C++ side is calling into a stream that// was destroyed while the receive operation was still in flight.//// Usage:// node repro-h2-abort.js//// Exit status 134 (SIGABRT) means it reproduced; 0 means it survived the run.// Reproduces over cleartext h2 (and equally over TLS), and does not depend on// --expose-gc.consthttp2=require('node:http2')constDURATION=Number(process.env.DURATION||5000)constSTREAMS=Number(process.env.STREAMS||8)// Small enough that HEADERS and DATA coalesce into a single read.constBODY=Buffer.alloc(Number(process.env.BODY||2048),'a')constserverOptions={settings: {maxConcurrentStreams: Number(process.env.MAX_CONCURRENT_STREAMS||4)}}constserver=http2.createServer(serverOptions)server.on('session',(session)=>session.on('error',()=>{}))// The whole trigger: tear the connection down from inside the stream handler,// i.e. while nghttp2 is still walking the buffer that delivered this stream.server.on('stream',(stream)=>{stream.on('error',()=>{})stream.session.destroy()})letrounds=0server.listen(0,'127.0.0.1',()=>{constport=server.address().portconstorigin=`http://127.0.0.1:${port}`letstopped=falseconstround=()=>{if(stopped)returnrounds++constsession=http2.connect(origin)session.on('error',()=>{})session.on('close',()=>setImmediate(round))session.on('connect',()=>{for(leti=0;i<STREAMS;i++){conststream=session.request({':path': `/${i}`,':method': 'POST'})stream.on('error',()=>{})stream.resume()stream.end(BODY)}})}round()setTimeout(()=>{stopped=trueconsole.log(`survived ${rounds} rounds without aborting`)server.close()process.exit(0)},DURATION)})
      $ node repro-h2-abort.jsAssertion failed: onread->IsFunction()Aborted (core dumped)
      $ echo$?134

      Does not depend on --expose-gc (4/4 with and without). Reproduces over TLS as well; the TLSWrap::ClearOut frame in the original stack was incidental.

      Affected versions

      buildresult (3 runs each)
      main @ 012ecf51d39, clean checkout3/3 abort
      v24.18.0 (release binary)0/3 — ~6,000 rounds clean
      v22.22.3 (release binary)0/3 — ~6,000 rounds clean

      Bisect

      Introduced by 46de80dhttp2: avoid uaf while receiving and sending rst_stream (#64166, fixing #64113):

      46de80de88c BAD (rc=134)
      46de80de88c~1 GOOD
      

      That commit defers session close while nghttp2_session_mem_recv() is in progress:

      if (is_receiving()) {
      set_close_pending();
      pending_close_code_ = code;
      pending_close_socket_closed_ = socket_closed;
      return; // finished later via MaybeFinishPendingClose()
      }

      Our reproduction destroys the session from inside a 'stream' handler, i.e. while receiving. Before that change the close completed immediately and the torn-down streams stopped receiving data. With the close deferred, nghttp2 keeps walking the same receive buffer and delivers DATA to streams whose JS wrappers have already been destroyed, so CallJSOnreadMethod finds onread unset.

      So the fix for one use-after-free in the mem_recv window appears to have opened a different lifetime hole in the same window. Note this is not addressed by ba6cb5c34c2 (http2: defer rst stream while in scope) — I cherry-picked that on top and it still aborts 5/5.

      Discovery

      Found via undici's HTTP/2 test suite, where it surfaced under borp as a bare 'test failed' with one subtest silently missing and no assertion text — worth knowing, since that signature is easy to mistake for flakiness. Running the test file directly is what exposes exit 134.

      cc @Eusgor@mcollina@pimterry@RafaelGSS

      Metadata

      Metadata

      Assignees

      Labels

      No labels
      No labels

      Type

      No type

      Projects

      No projects

        Milestone

        No milestone

        Relationships

        None yet

        Development

        No branches or pull requests

        Issue actions

        , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
        Skip to content

        http2: assertion failure onread->IsFunction() when a session is destroyed from a 'stream' handler #64850

        Description

        @mcollina

        What steps will reproduce the bug?

        node:http2 aborts on an internal assertion when an HTTP/2 session is destroyed from inside a 'stream' handler while the peer's DATA frames are still in the receive buffer:

        node[...]: v8::MaybeLocal<v8::Value> node::StreamBase::CallJSOnreadMethod(ssize_t, v8::Local<v8::ArrayBuffer>, std::size_t, StreamBaseJSChecks) at ../src/stream_base.cc:473
        Assertion failed: onread->IsFunction()
        1: node::Assert(node::AssertionInfo const&)
        2: node::StreamBase::CallJSOnreadMethod(...)
        3: node::http2::Http2StreamListener::OnStreamRead(long, uv_buf_t const&)
        4: node::http2::Http2Session::OnDataChunkReceived(nghttp2_session*, unsigned char, int, unsigned char const*, unsigned long, void*)
        5: nghttp2_session_mem_recv2
        6: node::http2::Http2Session::ConsumeHTTP2Data()
        7: node::http2::Http2Session::OnStreamRead(long, uv_buf_t const&)
        

        handle.onread is assigned in exactly one place — Http2Stream[kInit] in lib/internal/http2/core.js — so nghttp2 is delivering a DATA chunk to a stream whose JS wrapper has already been torn down.

        This is reachable from ordinary peer behaviour: the server simply destroys its own session from the 'stream' handler, which runs inside ConsumeHTTP2Data().

        Minimal reproduction

        No dependencies, no TLS. Aborts within a few hundred rounds, typically well under the 5s default window:

        'use strict'// Standalone reproduction — node:http2 only, no dependencies.//// An HTTP/2 server destroys its own session from inside the 'stream' handler,// which nghttp2 dispatches from within Http2Session::ConsumeHTTP2Data. The// client sends several concurrent POSTs whose HEADERS and DATA arrive in the// same receive buffer, so nghttp2 keeps delivering DATA for the remaining// streams after JS has torn the session down. Delivery then reaches a stream// whose JS wrapper is gone://// node::StreamBase::CallJSOnreadMethod at ../src/stream_base.cc:473// Assertion failed: onread->IsFunction()// <- node::http2::Http2StreamListener::OnStreamRead// <- node::http2::Http2Session::OnDataChunkReceived// <- nghttp2_session_mem_recv2// <- node::http2::Http2Session::ConsumeHTTP2Data//// handle.onread is assigned in exactly one place, Http2Stream[kInit]// (lib/internal/http2/core.js), so the C++ side is calling into a stream that// was destroyed while the receive operation was still in flight.//// Usage:// node repro-h2-abort.js//// Exit status 134 (SIGABRT) means it reproduced; 0 means it survived the run.// Reproduces over cleartext h2 (and equally over TLS), and does not depend on// --expose-gc.consthttp2=require('node:http2')constDURATION=Number(process.env.DURATION||5000)constSTREAMS=Number(process.env.STREAMS||8)// Small enough that HEADERS and DATA coalesce into a single read.constBODY=Buffer.alloc(Number(process.env.BODY||2048),'a')constserverOptions={settings: {maxConcurrentStreams: Number(process.env.MAX_CONCURRENT_STREAMS||4)}}constserver=http2.createServer(serverOptions)server.on('session',(session)=>session.on('error',()=>{}))// The whole trigger: tear the connection down from inside the stream handler,// i.e. while nghttp2 is still walking the buffer that delivered this stream.server.on('stream',(stream)=>{stream.on('error',()=>{})stream.session.destroy()})letrounds=0server.listen(0,'127.0.0.1',()=>{constport=server.address().portconstorigin=`http://127.0.0.1:${port}`letstopped=falseconstround=()=>{if(stopped)returnrounds++constsession=http2.connect(origin)session.on('error',()=>{})session.on('close',()=>setImmediate(round))session.on('connect',()=>{for(leti=0;i<STREAMS;i++){conststream=session.request({':path': `/${i}`,':method': 'POST'})stream.on('error',()=>{})stream.resume()stream.end(BODY)}})}round()setTimeout(()=>{stopped=trueconsole.log(`survived ${rounds} rounds without aborting`)server.close()process.exit(0)},DURATION)})
        $ node repro-h2-abort.jsAssertion failed: onread->IsFunction()Aborted (core dumped)
        $ echo$?134

        Does not depend on --expose-gc (4/4 with and without). Reproduces over TLS as well; the TLSWrap::ClearOut frame in the original stack was incidental.

        Affected versions

        buildresult (3 runs each)
        main @ 012ecf51d39, clean checkout3/3 abort
        v24.18.0 (release binary)0/3 — ~6,000 rounds clean
        v22.22.3 (release binary)0/3 — ~6,000 rounds clean

        Bisect

        Introduced by 46de80dhttp2: avoid uaf while receiving and sending rst_stream (#64166, fixing #64113):

        46de80de88c BAD (rc=134)
        46de80de88c~1 GOOD
        

        That commit defers session close while nghttp2_session_mem_recv() is in progress:

        if (is_receiving()) {
        set_close_pending();
        pending_close_code_ = code;
        pending_close_socket_closed_ = socket_closed;
        return; // finished later via MaybeFinishPendingClose()
        }

        Our reproduction destroys the session from inside a 'stream' handler, i.e. while receiving. Before that change the close completed immediately and the torn-down streams stopped receiving data. With the close deferred, nghttp2 keeps walking the same receive buffer and delivers DATA to streams whose JS wrappers have already been destroyed, so CallJSOnreadMethod finds onread unset.

        So the fix for one use-after-free in the mem_recv window appears to have opened a different lifetime hole in the same window. Note this is not addressed by ba6cb5c34c2 (http2: defer rst stream while in scope) — I cherry-picked that on top and it still aborts 5/5.

        Discovery

        Found via undici's HTTP/2 test suite, where it surfaced under borp as a bare 'test failed' with one subtest silently missing and no assertion text — worth knowing, since that signature is easy to mistake for flakiness. Running the test file directly is what exposes exit 134.

        cc @Eusgor@mcollina@pimterry@RafaelGSS

        Metadata

        Metadata

        Assignees

        Labels

        No labels
        No labels

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
          Skip to content

          http2: assertion failure onread->IsFunction() when a session is destroyed from a 'stream' handler #64850

          Description

          @mcollina

          What steps will reproduce the bug?

          node:http2 aborts on an internal assertion when an HTTP/2 session is destroyed from inside a 'stream' handler while the peer's DATA frames are still in the receive buffer:

          node[...]: v8::MaybeLocal<v8::Value> node::StreamBase::CallJSOnreadMethod(ssize_t, v8::Local<v8::ArrayBuffer>, std::size_t, StreamBaseJSChecks) at ../src/stream_base.cc:473
          Assertion failed: onread->IsFunction()
          1: node::Assert(node::AssertionInfo const&)
          2: node::StreamBase::CallJSOnreadMethod(...)
          3: node::http2::Http2StreamListener::OnStreamRead(long, uv_buf_t const&)
          4: node::http2::Http2Session::OnDataChunkReceived(nghttp2_session*, unsigned char, int, unsigned char const*, unsigned long, void*)
          5: nghttp2_session_mem_recv2
          6: node::http2::Http2Session::ConsumeHTTP2Data()
          7: node::http2::Http2Session::OnStreamRead(long, uv_buf_t const&)
          

          handle.onread is assigned in exactly one place — Http2Stream[kInit] in lib/internal/http2/core.js — so nghttp2 is delivering a DATA chunk to a stream whose JS wrapper has already been torn down.

          This is reachable from ordinary peer behaviour: the server simply destroys its own session from the 'stream' handler, which runs inside ConsumeHTTP2Data().

          Minimal reproduction

          No dependencies, no TLS. Aborts within a few hundred rounds, typically well under the 5s default window:

          'use strict'// Standalone reproduction — node:http2 only, no dependencies.//// An HTTP/2 server destroys its own session from inside the 'stream' handler,// which nghttp2 dispatches from within Http2Session::ConsumeHTTP2Data. The// client sends several concurrent POSTs whose HEADERS and DATA arrive in the// same receive buffer, so nghttp2 keeps delivering DATA for the remaining// streams after JS has torn the session down. Delivery then reaches a stream// whose JS wrapper is gone://// node::StreamBase::CallJSOnreadMethod at ../src/stream_base.cc:473// Assertion failed: onread->IsFunction()// <- node::http2::Http2StreamListener::OnStreamRead// <- node::http2::Http2Session::OnDataChunkReceived// <- nghttp2_session_mem_recv2// <- node::http2::Http2Session::ConsumeHTTP2Data//// handle.onread is assigned in exactly one place, Http2Stream[kInit]// (lib/internal/http2/core.js), so the C++ side is calling into a stream that// was destroyed while the receive operation was still in flight.//// Usage:// node repro-h2-abort.js//// Exit status 134 (SIGABRT) means it reproduced; 0 means it survived the run.// Reproduces over cleartext h2 (and equally over TLS), and does not depend on// --expose-gc.consthttp2=require('node:http2')constDURATION=Number(process.env.DURATION||5000)constSTREAMS=Number(process.env.STREAMS||8)// Small enough that HEADERS and DATA coalesce into a single read.constBODY=Buffer.alloc(Number(process.env.BODY||2048),'a')constserverOptions={settings: {maxConcurrentStreams: Number(process.env.MAX_CONCURRENT_STREAMS||4)}}constserver=http2.createServer(serverOptions)server.on('session',(session)=>session.on('error',()=>{}))// The whole trigger: tear the connection down from inside the stream handler,// i.e. while nghttp2 is still walking the buffer that delivered this stream.server.on('stream',(stream)=>{stream.on('error',()=>{})stream.session.destroy()})letrounds=0server.listen(0,'127.0.0.1',()=>{constport=server.address().portconstorigin=`http://127.0.0.1:${port}`letstopped=falseconstround=()=>{if(stopped)returnrounds++constsession=http2.connect(origin)session.on('error',()=>{})session.on('close',()=>setImmediate(round))session.on('connect',()=>{for(leti=0;i<STREAMS;i++){conststream=session.request({':path': `/${i}`,':method': 'POST'})stream.on('error',()=>{})stream.resume()stream.end(BODY)}})}round()setTimeout(()=>{stopped=trueconsole.log(`survived ${rounds} rounds without aborting`)server.close()process.exit(0)},DURATION)})
          $ node repro-h2-abort.jsAssertion failed: onread->IsFunction()Aborted (core dumped)
          $ echo$?134

          Does not depend on --expose-gc (4/4 with and without). Reproduces over TLS as well; the TLSWrap::ClearOut frame in the original stack was incidental.

          Affected versions

          buildresult (3 runs each)
          main @ 012ecf51d39, clean checkout3/3 abort
          v24.18.0 (release binary)0/3 — ~6,000 rounds clean
          v22.22.3 (release binary)0/3 — ~6,000 rounds clean

          Bisect

          Introduced by 46de80dhttp2: avoid uaf while receiving and sending rst_stream (#64166, fixing #64113):

          46de80de88c BAD (rc=134)
          46de80de88c~1 GOOD
          

          That commit defers session close while nghttp2_session_mem_recv() is in progress:

          if (is_receiving()) {
          set_close_pending();
          pending_close_code_ = code;
          pending_close_socket_closed_ = socket_closed;
          return; // finished later via MaybeFinishPendingClose()
          }

          Our reproduction destroys the session from inside a 'stream' handler, i.e. while receiving. Before that change the close completed immediately and the torn-down streams stopped receiving data. With the close deferred, nghttp2 keeps walking the same receive buffer and delivers DATA to streams whose JS wrappers have already been destroyed, so CallJSOnreadMethod finds onread unset.

          So the fix for one use-after-free in the mem_recv window appears to have opened a different lifetime hole in the same window. Note this is not addressed by ba6cb5c34c2 (http2: defer rst stream while in scope) — I cherry-picked that on top and it still aborts 5/5.

          Discovery

          Found via undici's HTTP/2 test suite, where it surfaced under borp as a bare 'test failed' with one subtest silently missing and no assertion text — worth knowing, since that signature is easy to mistake for flakiness. Running the test file directly is what exposes exit 134.

          cc @Eusgor@mcollina@pimterry@RafaelGSS

          Metadata

          Metadata

          Assignees

          Labels

          No labels
          No labels

          Type

          No type

          Projects

          No projects

            Milestone

            No milestone

            Relationships

            None yet

            Development

            No branches or pull requests

            Issue actions

            , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
            Skip to content

            http2: assertion failure onread->IsFunction() when a session is destroyed from a 'stream' handler #64850

            Description

            @mcollina

            What steps will reproduce the bug?

            node:http2 aborts on an internal assertion when an HTTP/2 session is destroyed from inside a 'stream' handler while the peer's DATA frames are still in the receive buffer:

            node[...]: v8::MaybeLocal<v8::Value> node::StreamBase::CallJSOnreadMethod(ssize_t, v8::Local<v8::ArrayBuffer>, std::size_t, StreamBaseJSChecks) at ../src/stream_base.cc:473
            Assertion failed: onread->IsFunction()
            1: node::Assert(node::AssertionInfo const&)
            2: node::StreamBase::CallJSOnreadMethod(...)
            3: node::http2::Http2StreamListener::OnStreamRead(long, uv_buf_t const&)
            4: node::http2::Http2Session::OnDataChunkReceived(nghttp2_session*, unsigned char, int, unsigned char const*, unsigned long, void*)
            5: nghttp2_session_mem_recv2
            6: node::http2::Http2Session::ConsumeHTTP2Data()
            7: node::http2::Http2Session::OnStreamRead(long, uv_buf_t const&)
            

            handle.onread is assigned in exactly one place — Http2Stream[kInit] in lib/internal/http2/core.js — so nghttp2 is delivering a DATA chunk to a stream whose JS wrapper has already been torn down.

            This is reachable from ordinary peer behaviour: the server simply destroys its own session from the 'stream' handler, which runs inside ConsumeHTTP2Data().

            Minimal reproduction

            No dependencies, no TLS. Aborts within a few hundred rounds, typically well under the 5s default window:

            'use strict'// Standalone reproduction — node:http2 only, no dependencies.//// An HTTP/2 server destroys its own session from inside the 'stream' handler,// which nghttp2 dispatches from within Http2Session::ConsumeHTTP2Data. The// client sends several concurrent POSTs whose HEADERS and DATA arrive in the// same receive buffer, so nghttp2 keeps delivering DATA for the remaining// streams after JS has torn the session down. Delivery then reaches a stream// whose JS wrapper is gone://// node::StreamBase::CallJSOnreadMethod at ../src/stream_base.cc:473// Assertion failed: onread->IsFunction()// <- node::http2::Http2StreamListener::OnStreamRead// <- node::http2::Http2Session::OnDataChunkReceived// <- nghttp2_session_mem_recv2// <- node::http2::Http2Session::ConsumeHTTP2Data//// handle.onread is assigned in exactly one place, Http2Stream[kInit]// (lib/internal/http2/core.js), so the C++ side is calling into a stream that// was destroyed while the receive operation was still in flight.//// Usage:// node repro-h2-abort.js//// Exit status 134 (SIGABRT) means it reproduced; 0 means it survived the run.// Reproduces over cleartext h2 (and equally over TLS), and does not depend on// --expose-gc.consthttp2=require('node:http2')constDURATION=Number(process.env.DURATION||5000)constSTREAMS=Number(process.env.STREAMS||8)// Small enough that HEADERS and DATA coalesce into a single read.constBODY=Buffer.alloc(Number(process.env.BODY||2048),'a')constserverOptions={settings: {maxConcurrentStreams: Number(process.env.MAX_CONCURRENT_STREAMS||4)}}constserver=http2.createServer(serverOptions)server.on('session',(session)=>session.on('error',()=>{}))// The whole trigger: tear the connection down from inside the stream handler,// i.e. while nghttp2 is still walking the buffer that delivered this stream.server.on('stream',(stream)=>{stream.on('error',()=>{})stream.session.destroy()})letrounds=0server.listen(0,'127.0.0.1',()=>{constport=server.address().portconstorigin=`http://127.0.0.1:${port}`letstopped=falseconstround=()=>{if(stopped)returnrounds++constsession=http2.connect(origin)session.on('error',()=>{})session.on('close',()=>setImmediate(round))session.on('connect',()=>{for(leti=0;i<STREAMS;i++){conststream=session.request({':path': `/${i}`,':method': 'POST'})stream.on('error',()=>{})stream.resume()stream.end(BODY)}})}round()setTimeout(()=>{stopped=trueconsole.log(`survived ${rounds} rounds without aborting`)server.close()process.exit(0)},DURATION)})
            $ node repro-h2-abort.jsAssertion failed: onread->IsFunction()Aborted (core dumped)
            $ echo$?134

            Does not depend on --expose-gc (4/4 with and without). Reproduces over TLS as well; the TLSWrap::ClearOut frame in the original stack was incidental.

            Affected versions

            buildresult (3 runs each)
            main @ 012ecf51d39, clean checkout3/3 abort
            v24.18.0 (release binary)0/3 — ~6,000 rounds clean
            v22.22.3 (release binary)0/3 — ~6,000 rounds clean

            Bisect

            Introduced by 46de80dhttp2: avoid uaf while receiving and sending rst_stream (#64166, fixing #64113):

            46de80de88c BAD (rc=134)
            46de80de88c~1 GOOD
            

            That commit defers session close while nghttp2_session_mem_recv() is in progress:

            if (is_receiving()) {
            set_close_pending();
            pending_close_code_ = code;
            pending_close_socket_closed_ = socket_closed;
            return; // finished later via MaybeFinishPendingClose()
            }

            Our reproduction destroys the session from inside a 'stream' handler, i.e. while receiving. Before that change the close completed immediately and the torn-down streams stopped receiving data. With the close deferred, nghttp2 keeps walking the same receive buffer and delivers DATA to streams whose JS wrappers have already been destroyed, so CallJSOnreadMethod finds onread unset.

            So the fix for one use-after-free in the mem_recv window appears to have opened a different lifetime hole in the same window. Note this is not addressed by ba6cb5c34c2 (http2: defer rst stream while in scope) — I cherry-picked that on top and it still aborts 5/5.

            Discovery

            Found via undici's HTTP/2 test suite, where it surfaced under borp as a bare 'test failed' with one subtest silently missing and no assertion text — worth knowing, since that signature is easy to mistake for flakiness. Running the test file directly is what exposes exit 134.

            cc @Eusgor@mcollina@pimterry@RafaelGSS

            Metadata

            Metadata

            Assignees

            Labels

            No labels
            No labels

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              http2: assertion failure onread->IsFunction() when a session is destroyed from a 'stream' handler #64850

              Description

              @mcollina

              What steps will reproduce the bug?

              node:http2 aborts on an internal assertion when an HTTP/2 session is destroyed from inside a 'stream' handler while the peer's DATA frames are still in the receive buffer:

              node[...]: v8::MaybeLocal<v8::Value> node::StreamBase::CallJSOnreadMethod(ssize_t, v8::Local<v8::ArrayBuffer>, std::size_t, StreamBaseJSChecks) at ../src/stream_base.cc:473
              Assertion failed: onread->IsFunction()
              1: node::Assert(node::AssertionInfo const&)
              2: node::StreamBase::CallJSOnreadMethod(...)
              3: node::http2::Http2StreamListener::OnStreamRead(long, uv_buf_t const&)
              4: node::http2::Http2Session::OnDataChunkReceived(nghttp2_session*, unsigned char, int, unsigned char const*, unsigned long, void*)
              5: nghttp2_session_mem_recv2
              6: node::http2::Http2Session::ConsumeHTTP2Data()
              7: node::http2::Http2Session::OnStreamRead(long, uv_buf_t const&)
              

              handle.onread is assigned in exactly one place — Http2Stream[kInit] in lib/internal/http2/core.js — so nghttp2 is delivering a DATA chunk to a stream whose JS wrapper has already been torn down.

              This is reachable from ordinary peer behaviour: the server simply destroys its own session from the 'stream' handler, which runs inside ConsumeHTTP2Data().

              Minimal reproduction

              No dependencies, no TLS. Aborts within a few hundred rounds, typically well under the 5s default window:

              'use strict'// Standalone reproduction — node:http2 only, no dependencies.//// An HTTP/2 server destroys its own session from inside the 'stream' handler,// which nghttp2 dispatches from within Http2Session::ConsumeHTTP2Data. The// client sends several concurrent POSTs whose HEADERS and DATA arrive in the// same receive buffer, so nghttp2 keeps delivering DATA for the remaining// streams after JS has torn the session down. Delivery then reaches a stream// whose JS wrapper is gone://// node::StreamBase::CallJSOnreadMethod at ../src/stream_base.cc:473// Assertion failed: onread->IsFunction()// <- node::http2::Http2StreamListener::OnStreamRead// <- node::http2::Http2Session::OnDataChunkReceived// <- nghttp2_session_mem_recv2// <- node::http2::Http2Session::ConsumeHTTP2Data//// handle.onread is assigned in exactly one place, Http2Stream[kInit]// (lib/internal/http2/core.js), so the C++ side is calling into a stream that// was destroyed while the receive operation was still in flight.//// Usage:// node repro-h2-abort.js//// Exit status 134 (SIGABRT) means it reproduced; 0 means it survived the run.// Reproduces over cleartext h2 (and equally over TLS), and does not depend on// --expose-gc.consthttp2=require('node:http2')constDURATION=Number(process.env.DURATION||5000)constSTREAMS=Number(process.env.STREAMS||8)// Small enough that HEADERS and DATA coalesce into a single read.constBODY=Buffer.alloc(Number(process.env.BODY||2048),'a')constserverOptions={settings: {maxConcurrentStreams: Number(process.env.MAX_CONCURRENT_STREAMS||4)}}constserver=http2.createServer(serverOptions)server.on('session',(session)=>session.on('error',()=>{}))// The whole trigger: tear the connection down from inside the stream handler,// i.e. while nghttp2 is still walking the buffer that delivered this stream.server.on('stream',(stream)=>{stream.on('error',()=>{})stream.session.destroy()})letrounds=0server.listen(0,'127.0.0.1',()=>{constport=server.address().portconstorigin=`http://127.0.0.1:${port}`letstopped=falseconstround=()=>{if(stopped)returnrounds++constsession=http2.connect(origin)session.on('error',()=>{})session.on('close',()=>setImmediate(round))session.on('connect',()=>{for(leti=0;i<STREAMS;i++){conststream=session.request({':path': `/${i}`,':method': 'POST'})stream.on('error',()=>{})stream.resume()stream.end(BODY)}})}round()setTimeout(()=>{stopped=trueconsole.log(`survived ${rounds} rounds without aborting`)server.close()process.exit(0)},DURATION)})
              $ node repro-h2-abort.jsAssertion failed: onread->IsFunction()Aborted (core dumped)
              $ echo$?134

              Does not depend on --expose-gc (4/4 with and without). Reproduces over TLS as well; the TLSWrap::ClearOut frame in the original stack was incidental.

              Affected versions

              buildresult (3 runs each)
              main @ 012ecf51d39, clean checkout3/3 abort
              v24.18.0 (release binary)0/3 — ~6,000 rounds clean
              v22.22.3 (release binary)0/3 — ~6,000 rounds clean

              Bisect

              Introduced by 46de80dhttp2: avoid uaf while receiving and sending rst_stream (#64166, fixing #64113):

              46de80de88c BAD (rc=134)
              46de80de88c~1 GOOD
              

              That commit defers session close while nghttp2_session_mem_recv() is in progress:

              if (is_receiving()) {
              set_close_pending();
              pending_close_code_ = code;
              pending_close_socket_closed_ = socket_closed;
              return; // finished later via MaybeFinishPendingClose()
              }

              Our reproduction destroys the session from inside a 'stream' handler, i.e. while receiving. Before that change the close completed immediately and the torn-down streams stopped receiving data. With the close deferred, nghttp2 keeps walking the same receive buffer and delivers DATA to streams whose JS wrappers have already been destroyed, so CallJSOnreadMethod finds onread unset.

              So the fix for one use-after-free in the mem_recv window appears to have opened a different lifetime hole in the same window. Note this is not addressed by ba6cb5c34c2 (http2: defer rst stream while in scope) — I cherry-picked that on top and it still aborts 5/5.

              Discovery

              Found via undici's HTTP/2 test suite, where it surfaced under borp as a bare 'test failed' with one subtest silently missing and no assertion text — worth knowing, since that signature is easy to mistake for flakiness. Running the test file directly is what exposes exit 134.

              cc @Eusgor@mcollina@pimterry@RafaelGSS

              Metadata

              Metadata

              Assignees

              Labels

              No labels
              No labels

              Type

              No type

              Projects

              No projects

                Milestone

                No milestone

                Relationships

                None yet

                Development

                No branches or pull requests

                Issue actions

                , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                Skip to content

                http2: assertion failure onread->IsFunction() when a session is destroyed from a 'stream' handler #64850

                Description

                @mcollina

                What steps will reproduce the bug?

                node:http2 aborts on an internal assertion when an HTTP/2 session is destroyed from inside a 'stream' handler while the peer's DATA frames are still in the receive buffer:

                node[...]: v8::MaybeLocal<v8::Value> node::StreamBase::CallJSOnreadMethod(ssize_t, v8::Local<v8::ArrayBuffer>, std::size_t, StreamBaseJSChecks) at ../src/stream_base.cc:473
                Assertion failed: onread->IsFunction()
                1: node::Assert(node::AssertionInfo const&)
                2: node::StreamBase::CallJSOnreadMethod(...)
                3: node::http2::Http2StreamListener::OnStreamRead(long, uv_buf_t const&)
                4: node::http2::Http2Session::OnDataChunkReceived(nghttp2_session*, unsigned char, int, unsigned char const*, unsigned long, void*)
                5: nghttp2_session_mem_recv2
                6: node::http2::Http2Session::ConsumeHTTP2Data()
                7: node::http2::Http2Session::OnStreamRead(long, uv_buf_t const&)
                

                handle.onread is assigned in exactly one place — Http2Stream[kInit] in lib/internal/http2/core.js — so nghttp2 is delivering a DATA chunk to a stream whose JS wrapper has already been torn down.

                This is reachable from ordinary peer behaviour: the server simply destroys its own session from the 'stream' handler, which runs inside ConsumeHTTP2Data().

                Minimal reproduction

                No dependencies, no TLS. Aborts within a few hundred rounds, typically well under the 5s default window:

                'use strict'// Standalone reproduction — node:http2 only, no dependencies.//// An HTTP/2 server destroys its own session from inside the 'stream' handler,// which nghttp2 dispatches from within Http2Session::ConsumeHTTP2Data. The// client sends several concurrent POSTs whose HEADERS and DATA arrive in the// same receive buffer, so nghttp2 keeps delivering DATA for the remaining// streams after JS has torn the session down. Delivery then reaches a stream// whose JS wrapper is gone://// node::StreamBase::CallJSOnreadMethod at ../src/stream_base.cc:473// Assertion failed: onread->IsFunction()// <- node::http2::Http2StreamListener::OnStreamRead// <- node::http2::Http2Session::OnDataChunkReceived// <- nghttp2_session_mem_recv2// <- node::http2::Http2Session::ConsumeHTTP2Data//// handle.onread is assigned in exactly one place, Http2Stream[kInit]// (lib/internal/http2/core.js), so the C++ side is calling into a stream that// was destroyed while the receive operation was still in flight.//// Usage:// node repro-h2-abort.js//// Exit status 134 (SIGABRT) means it reproduced; 0 means it survived the run.// Reproduces over cleartext h2 (and equally over TLS), and does not depend on// --expose-gc.consthttp2=require('node:http2')constDURATION=Number(process.env.DURATION||5000)constSTREAMS=Number(process.env.STREAMS||8)// Small enough that HEADERS and DATA coalesce into a single read.constBODY=Buffer.alloc(Number(process.env.BODY||2048),'a')constserverOptions={settings: {maxConcurrentStreams: Number(process.env.MAX_CONCURRENT_STREAMS||4)}}constserver=http2.createServer(serverOptions)server.on('session',(session)=>session.on('error',()=>{}))// The whole trigger: tear the connection down from inside the stream handler,// i.e. while nghttp2 is still walking the buffer that delivered this stream.server.on('stream',(stream)=>{stream.on('error',()=>{})stream.session.destroy()})letrounds=0server.listen(0,'127.0.0.1',()=>{constport=server.address().portconstorigin=`http://127.0.0.1:${port}`letstopped=falseconstround=()=>{if(stopped)returnrounds++constsession=http2.connect(origin)session.on('error',()=>{})session.on('close',()=>setImmediate(round))session.on('connect',()=>{for(leti=0;i<STREAMS;i++){conststream=session.request({':path': `/${i}`,':method': 'POST'})stream.on('error',()=>{})stream.resume()stream.end(BODY)}})}round()setTimeout(()=>{stopped=trueconsole.log(`survived ${rounds} rounds without aborting`)server.close()process.exit(0)},DURATION)})
                $ node repro-h2-abort.jsAssertion failed: onread->IsFunction()Aborted (core dumped)
                $ echo$?134

                Does not depend on --expose-gc (4/4 with and without). Reproduces over TLS as well; the TLSWrap::ClearOut frame in the original stack was incidental.

                Affected versions

                buildresult (3 runs each)
                main @ 012ecf51d39, clean checkout3/3 abort
                v24.18.0 (release binary)0/3 — ~6,000 rounds clean
                v22.22.3 (release binary)0/3 — ~6,000 rounds clean

                Bisect

                Introduced by 46de80dhttp2: avoid uaf while receiving and sending rst_stream (#64166, fixing #64113):

                46de80de88c BAD (rc=134)
                46de80de88c~1 GOOD
                

                That commit defers session close while nghttp2_session_mem_recv() is in progress:

                if (is_receiving()) {
                set_close_pending();
                pending_close_code_ = code;
                pending_close_socket_closed_ = socket_closed;
                return; // finished later via MaybeFinishPendingClose()
                }

                Our reproduction destroys the session from inside a 'stream' handler, i.e. while receiving. Before that change the close completed immediately and the torn-down streams stopped receiving data. With the close deferred, nghttp2 keeps walking the same receive buffer and delivers DATA to streams whose JS wrappers have already been destroyed, so CallJSOnreadMethod finds onread unset.

                So the fix for one use-after-free in the mem_recv window appears to have opened a different lifetime hole in the same window. Note this is not addressed by ba6cb5c34c2 (http2: defer rst stream while in scope) — I cherry-picked that on top and it still aborts 5/5.

                Discovery

                Found via undici's HTTP/2 test suite, where it surfaced under borp as a bare 'test failed' with one subtest silently missing and no assertion text — worth knowing, since that signature is easy to mistake for flakiness. Running the test file directly is what exposes exit 134.

                cc @Eusgor@mcollina@pimterry@RafaelGSS

                Metadata

                Metadata

                Assignees

                Labels

                No labels
                No labels

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions