ExportXMLWordPrintableJSON

    • Type: Bug
    • Resolution: Fixed
    • Priority: Major - P3
    • 8.3.16, 9.0.7, 8.0.37, 7.0.48
    • Affects Version/s: None
    • Component/s: None
    • None
    • Query Execution
    • Fully Compatible
    • ALL
    • v9.0, v8.3, v8.0, v7.0
    • QE 2026-09-28, QE 2026-10-12, QE 2026-10-26
    • None
    • None
    • None
    • None
    • None
    • None
    • None

      Summary

      The per-statement sort field of the update and bulkWrite commands is declared as IDL type object, which is an unowned BSONObj view into the wire message buffer. The sort is threaded through the planner into MergeSortStats::sortPattern, which the plan cache retains in its debug info. Once the originating command completes its message buffer is freed, leaving the cached entry holding a dangling BSONObj. A later $planCacheStats aggregation serializes that entry and reads the freed region, copying its contents into the response returned to the client.

      Confirmed under ASAN as a heap-use-after-free. See SECBUG-1570 for the reproducer; it is deliberately not repeated here.

      Root cause

      The sort reaches the plan cache without ever being copied:

      • src/mongo/db/query/write_ops/write_ops.idl — UpdateOpEntry.sort is type: object (unowned), unlike FindAndModifyCommandRequest.sort and find's sort, which are object_owned.
      • src/mongo/db/commands/query_cmd/bulk_write.idl — BulkWriteUpdateOp.sort likewise.

      Every hop from there to the execution stage is a plain BSONObj copy, which preserves whatever ownership the source had:

      planner_access.cpp:1977        msn->sort =      
                                     query.getFindCommandRequest().getSort();
      query_solution.cpp:719         copy->sort = this->sort;
      classic_stage_builder.cpp:306  params.pattern = msn->sort;
      merge_sort.cpp:30              _pattern(params.pattern)
      merge_sort.cpp:238             _specificStats.sortPattern = _pattern;
      

      The read happens at src/mongo/db/query/plan_explainer_impl.cpp, in the STAGE_SORT_MERGE branch of appendStageStructure():

      } else if (STAGE_SORT_MERGE == stats.stageType) {
          MergeSortStats* spec = static_cast<MergeSortStats*>(stats.specific.get());
          bob->append("sortPattern", spec->sortPattern);   // <-- reads freed memory
      

      ASAN stack (abridged), from an Evergreen patch with the fix reverted:

      ERROR: AddressSanitizer: heap-use-after-free
      READ of size 4 at 0x5140000926fa thread T80 (conn10)
          #7  mongo::BSONObj::objsize() bsonobj.h:485
          #8  mongo::BSONObjBuilderBase<...>::append(...) bsonobjbuilder.h:173
          #9  appendStageStructure(...) plan_explainer_impl.cpp:376
          #14 mongo::getCachedPlanStats(...) plan_explainer_impl.cpp:1730
          #15 mongo::Explain::planCacheEntryToBSON(...) explain.cpp:791
          #16 CommonMongodProcessInterface::getMatchingPlanCacheEntryStats(...)
      
      freed by thread T80 (conn10) here:
          #1  std::default_delete<transport::SessionWorkflow::Impl::WorkItem>::operator()(...)
      
      previously allocated by thread T80 (conn10) here:
          #3  mongo::allocator_aware::SharedBuffer<...>::SharedBuffer(...)
          #5  transport::CommonAsioSession::sourceMessageImpl(...)
      

      Allocated as the wire message buffer, freed when the write command's session-workflow iteration completes, read afterwards by $planCacheStats.

            Assignee:
            Daniel Tabacaru
            Reporter:
            Daniel Tabacaru
            Votes:
            0 Vote for this issue
            Watchers:
            3 Start watching this issue

              Created:
              Updated:
              Resolved: