Skip to content

JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ #129176

Description

@AndyAyersMS

JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ

Note

This issue was investigated and drafted with GitHub Copilot CLI.

Description

optDeriveLoopCloningConditions derives a limit <relop> arr.Length condition for the per-access bounds-check elision, where limit is the loop test's RHS. For increasing loops this is sound because the visited indices are [init, limit) and init is independently constrained >= 0. For decreasing loops the visited indices are [limit+1, init], so the value that must fit in the indexed array is init, not limit. The current code only constrains init against the array length when the limit is a constant or an invariant local — when HasArrayLengthLimit is true, ident is set to the limit (loopcloning.cpp ~line 1419) and no bound on init is emitted, so the fast clone (with bounds checks removed) is taken even when init is far outside the indexed array.

This is benign in the very common shape for (i = a.Length - 1; i > 0; i--) a[i]… because there init and the indexed array agree and the array-deref/length condition implicitly bounds init. It becomes unsafe as soon as the loop test's limit array differs from the array being indexed inside the body.

Reproduction

Repository: dotnet/runtime
Tested on: main at 9593745 (also reproduces on .NET 10 RTM)
Configuration: windows.x64.Checked, FullOpts, tiering disabled.

usingSystem;usingSystem.Runtime.CompilerServices;publicclassP{[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGT(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>limitArr.Length;i--)sum+=accessArr[i];returnsum;}[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGE(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>=limitArr.Length;i--)sum+=accessArr[i];returnsum;}publicstaticintMain(){Console.WriteLine("=== GT ===");try{intx=LatentBugGT(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}Console.WriteLine("=== GE ===");try{intx=LatentBugGE(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}return100;}}

Expected

Both calls index accessArr[1000] (length 5) and must throw IndexOutOfRangeException.

Actual

=== GT ===
BUG: returned 0
=== GE ===
BUG: returned 0

Both loops silently iterate i = 1000, 999, …, 2 (or …, 1 for GE) and dereference accessArr[i] for ~999 out-of-bounds indices with no exception. Whatever the GC heap happens to contain at those offsets is summed and returned. (In the small repro above the sum happens to be 0; with arbitrary heap state this would silently corrupt analysis results, or — for write loops — silently corrupt heap memory.)

Cloning conditions emitted

JIT dump from the unmodified clrjit.dll:

Conditions: (V04 GE 0) && (V00.Length LT V01.Length)
Considering condition 0: (V04 GE 0), could not be evaluated
Considering condition 1: (V00.Length LT V01.Length), could not be evaluated
Loops cloned: 1
Loops statically optimized: 0

V04 = startIdx, V00 = limitArr, V01 = accessArr. With the bad inputs startIdx=1000, limitArr.Length=1, accessArr.Length=5:

conditionactual
startIdx >= 01000 >= 0
limitArr.Length < accessArr.Length1 < 5

Both pass → fast clone runs without bounds checks → OOB reads of accessArr.

Root cause

In src/coreclr/jit/loopcloning.cppoptDeriveLoopCloningConditions:

  • For HasConstInit decreasing loops, ident = init (line ~1348).
  • For variable-init decreasing loops, ident = initVar (line ~1370).
  • For HasArrayLengthLimit, ident is unconditionally set to the limit array's length (~line 1419), regardless of direction. The decreasing path then emits cond(opLimitCondition, ident, accessArr.Length) which evaluates to limitArr.Length < accessArr.Length — a constraint on limit, not init.

The cloning condition needed for soundness in the decreasing direction is init < accessArr.Length (for GT) or init <= accessArr.Length (for GE), independent of what the limit array is.

Suggested fix

For decreasing loops with HasArrayLengthLimit, do not let the limit array length be used as ident. Instead, always materialize an independent init-based identifier (from iterInfo->IterVar at the preheader, or iterInfo->ConstInitValue) and emit the per-access init <relop> accessArr.Length condition against it. The increasing-loop path can be left as is.

Why this isn't fixed in PR for #84697

This was found while reviewing the changes for #84697 (bounds-check elimination for i != arr.Length loops). To keep that PR's blast radius bounded I restricted GT_NE support to increasing loops only, with an explicit comment noting that decreasing GT_NE would have inherited (and widened) this bug into the very common for (i = end; i != start; i--) idiom. The regression test in that PR (Runtime_84697.SumDecreasingNETwoArrays) pins the safe behavior for GT_NE. The pre-existing GT_GT/GT_GE versions documented here remain.

Suggested area / labels

  • area-CodeGen-coreclr
  • tenet-correctness
  • bug

category

correctness

theme

loop-cloning · bounds-checks

skill-level

expert

cost

medium

impact

medium (correctness — silent OOB read in an uncommon but legitimate code shape)

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

    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)) { // Add copy buttons to all
       blocks
      (function() {
      function addCopyButtons() {
      document.querySelectorAll('pre code').forEach(function(codeBlock) {
      if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
      codeBlock.parentElement.setAttribute('data-copy-added', 'true');
      var btn = document.createElement('button');
      btn.textContent = 'Copy';
      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;';
      btn.onmouseover = function() { this.style.opacity = '1'; };
      btn.onmouseout = function() { this.style.opacity = '0.7'; };
      btn.onclick = function() {
      navigator.clipboard.writeText(codeBlock.textContent).then(function() {
      btn.textContent = 'Copied!';
      setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
      });
      };
      codeBlock.parentElement.style.position = 'relative';
      codeBlock.parentElement.appendChild(btn);
      });
      }
      addCopyButtons();
      // Re-run on dynamic content
      var observer = new MutationObserver(addCopyButtons);
      observer.observe(document.body, { childList: true, subtree: true });
      })();
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ · Issue #129176 · dotnet/runtime · GitHub
      Skip to content

      JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ #129176

      Description

      @AndyAyersMS

      JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ

      Note

      This issue was investigated and drafted with GitHub Copilot CLI.

      Description

      optDeriveLoopCloningConditions derives a limit <relop> arr.Length condition for the per-access bounds-check elision, where limit is the loop test's RHS. For increasing loops this is sound because the visited indices are [init, limit) and init is independently constrained >= 0. For decreasing loops the visited indices are [limit+1, init], so the value that must fit in the indexed array is init, not limit. The current code only constrains init against the array length when the limit is a constant or an invariant local — when HasArrayLengthLimit is true, ident is set to the limit (loopcloning.cpp ~line 1419) and no bound on init is emitted, so the fast clone (with bounds checks removed) is taken even when init is far outside the indexed array.

      This is benign in the very common shape for (i = a.Length - 1; i > 0; i--) a[i]… because there init and the indexed array agree and the array-deref/length condition implicitly bounds init. It becomes unsafe as soon as the loop test's limit array differs from the array being indexed inside the body.

      Reproduction

      Repository: dotnet/runtime
      Tested on: main at 9593745 (also reproduces on .NET 10 RTM)
      Configuration: windows.x64.Checked, FullOpts, tiering disabled.

      usingSystem;usingSystem.Runtime.CompilerServices;publicclassP{[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGT(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>limitArr.Length;i--)sum+=accessArr[i];returnsum;}[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGE(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>=limitArr.Length;i--)sum+=accessArr[i];returnsum;}publicstaticintMain(){Console.WriteLine("=== GT ===");try{intx=LatentBugGT(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}Console.WriteLine("=== GE ===");try{intx=LatentBugGE(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}return100;}}

      Expected

      Both calls index accessArr[1000] (length 5) and must throw IndexOutOfRangeException.

      Actual

      === GT ===
      BUG: returned 0
      === GE ===
      BUG: returned 0
      

      Both loops silently iterate i = 1000, 999, …, 2 (or …, 1 for GE) and dereference accessArr[i] for ~999 out-of-bounds indices with no exception. Whatever the GC heap happens to contain at those offsets is summed and returned. (In the small repro above the sum happens to be 0; with arbitrary heap state this would silently corrupt analysis results, or — for write loops — silently corrupt heap memory.)

      Cloning conditions emitted

      JIT dump from the unmodified clrjit.dll:

      Conditions: (V04 GE 0) && (V00.Length LT V01.Length)
      Considering condition 0: (V04 GE 0), could not be evaluated
      Considering condition 1: (V00.Length LT V01.Length), could not be evaluated
      Loops cloned: 1
      Loops statically optimized: 0
      

      V04 = startIdx, V00 = limitArr, V01 = accessArr. With the bad inputs startIdx=1000, limitArr.Length=1, accessArr.Length=5:

      conditionactual
      startIdx >= 01000 >= 0
      limitArr.Length < accessArr.Length1 < 5

      Both pass → fast clone runs without bounds checks → OOB reads of accessArr.

      Root cause

      In src/coreclr/jit/loopcloning.cppoptDeriveLoopCloningConditions:

      • For HasConstInit decreasing loops, ident = init (line ~1348).
      • For variable-init decreasing loops, ident = initVar (line ~1370).
      • For HasArrayLengthLimit, ident is unconditionally set to the limit array's length (~line 1419), regardless of direction. The decreasing path then emits cond(opLimitCondition, ident, accessArr.Length) which evaluates to limitArr.Length < accessArr.Length — a constraint on limit, not init.

      The cloning condition needed for soundness in the decreasing direction is init < accessArr.Length (for GT) or init <= accessArr.Length (for GE), independent of what the limit array is.

      Suggested fix

      For decreasing loops with HasArrayLengthLimit, do not let the limit array length be used as ident. Instead, always materialize an independent init-based identifier (from iterInfo->IterVar at the preheader, or iterInfo->ConstInitValue) and emit the per-access init <relop> accessArr.Length condition against it. The increasing-loop path can be left as is.

      Why this isn't fixed in PR for #84697

      This was found while reviewing the changes for #84697 (bounds-check elimination for i != arr.Length loops). To keep that PR's blast radius bounded I restricted GT_NE support to increasing loops only, with an explicit comment noting that decreasing GT_NE would have inherited (and widened) this bug into the very common for (i = end; i != start; i--) idiom. The regression test in that PR (Runtime_84697.SumDecreasingNETwoArrays) pins the safe behavior for GT_NE. The pre-existing GT_GT/GT_GE versions documented here remain.

      Suggested area / labels

      • area-CodeGen-coreclr
      • tenet-correctness
      • bug

      category

      correctness

      theme

      loop-cloning · bounds-checks

      skill-level

      expert

      cost

      medium

      impact

      medium (correctness — silent OOB read in an uncommon but legitimate code shape)

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

        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)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ · Issue #129176 · dotnet/runtime · GitHub
          Skip to content

          JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ #129176

          Description

          @AndyAyersMS

          JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ

          Note

          This issue was investigated and drafted with GitHub Copilot CLI.

          Description

          optDeriveLoopCloningConditions derives a limit <relop> arr.Length condition for the per-access bounds-check elision, where limit is the loop test's RHS. For increasing loops this is sound because the visited indices are [init, limit) and init is independently constrained >= 0. For decreasing loops the visited indices are [limit+1, init], so the value that must fit in the indexed array is init, not limit. The current code only constrains init against the array length when the limit is a constant or an invariant local — when HasArrayLengthLimit is true, ident is set to the limit (loopcloning.cpp ~line 1419) and no bound on init is emitted, so the fast clone (with bounds checks removed) is taken even when init is far outside the indexed array.

          This is benign in the very common shape for (i = a.Length - 1; i > 0; i--) a[i]… because there init and the indexed array agree and the array-deref/length condition implicitly bounds init. It becomes unsafe as soon as the loop test's limit array differs from the array being indexed inside the body.

          Reproduction

          Repository: dotnet/runtime
          Tested on: main at 9593745 (also reproduces on .NET 10 RTM)
          Configuration: windows.x64.Checked, FullOpts, tiering disabled.

          usingSystem;usingSystem.Runtime.CompilerServices;publicclassP{[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGT(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>limitArr.Length;i--)sum+=accessArr[i];returnsum;}[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGE(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>=limitArr.Length;i--)sum+=accessArr[i];returnsum;}publicstaticintMain(){Console.WriteLine("=== GT ===");try{intx=LatentBugGT(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}Console.WriteLine("=== GE ===");try{intx=LatentBugGE(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}return100;}}

          Expected

          Both calls index accessArr[1000] (length 5) and must throw IndexOutOfRangeException.

          Actual

          === GT ===
          BUG: returned 0
          === GE ===
          BUG: returned 0
          

          Both loops silently iterate i = 1000, 999, …, 2 (or …, 1 for GE) and dereference accessArr[i] for ~999 out-of-bounds indices with no exception. Whatever the GC heap happens to contain at those offsets is summed and returned. (In the small repro above the sum happens to be 0; with arbitrary heap state this would silently corrupt analysis results, or — for write loops — silently corrupt heap memory.)

          Cloning conditions emitted

          JIT dump from the unmodified clrjit.dll:

          Conditions: (V04 GE 0) && (V00.Length LT V01.Length)
          Considering condition 0: (V04 GE 0), could not be evaluated
          Considering condition 1: (V00.Length LT V01.Length), could not be evaluated
          Loops cloned: 1
          Loops statically optimized: 0
          

          V04 = startIdx, V00 = limitArr, V01 = accessArr. With the bad inputs startIdx=1000, limitArr.Length=1, accessArr.Length=5:

          conditionactual
          startIdx >= 01000 >= 0
          limitArr.Length < accessArr.Length1 < 5

          Both pass → fast clone runs without bounds checks → OOB reads of accessArr.

          Root cause

          In src/coreclr/jit/loopcloning.cppoptDeriveLoopCloningConditions:

          • For HasConstInit decreasing loops, ident = init (line ~1348).
          • For variable-init decreasing loops, ident = initVar (line ~1370).
          • For HasArrayLengthLimit, ident is unconditionally set to the limit array's length (~line 1419), regardless of direction. The decreasing path then emits cond(opLimitCondition, ident, accessArr.Length) which evaluates to limitArr.Length < accessArr.Length — a constraint on limit, not init.

          The cloning condition needed for soundness in the decreasing direction is init < accessArr.Length (for GT) or init <= accessArr.Length (for GE), independent of what the limit array is.

          Suggested fix

          For decreasing loops with HasArrayLengthLimit, do not let the limit array length be used as ident. Instead, always materialize an independent init-based identifier (from iterInfo->IterVar at the preheader, or iterInfo->ConstInitValue) and emit the per-access init <relop> accessArr.Length condition against it. The increasing-loop path can be left as is.

          Why this isn't fixed in PR for #84697

          This was found while reviewing the changes for #84697 (bounds-check elimination for i != arr.Length loops). To keep that PR's blast radius bounded I restricted GT_NE support to increasing loops only, with an explicit comment noting that decreasing GT_NE would have inherited (and widened) this bug into the very common for (i = end; i != start; i--) idiom. The regression test in that PR (Runtime_84697.SumDecreasingNETwoArrays) pins the safe behavior for GT_NE. The pre-existing GT_GT/GT_GE versions documented here remain.

          Suggested area / labels

          • area-CodeGen-coreclr
          • tenet-correctness
          • bug

          category

          correctness

          theme

          loop-cloning · bounds-checks

          skill-level

          expert

          cost

          medium

          impact

          medium (correctness — silent OOB read in an uncommon but legitimate code shape)

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

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

              JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ #129176

              Description

              @AndyAyersMS

              JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ

              Note

              This issue was investigated and drafted with GitHub Copilot CLI.

              Description

              optDeriveLoopCloningConditions derives a limit <relop> arr.Length condition for the per-access bounds-check elision, where limit is the loop test's RHS. For increasing loops this is sound because the visited indices are [init, limit) and init is independently constrained >= 0. For decreasing loops the visited indices are [limit+1, init], so the value that must fit in the indexed array is init, not limit. The current code only constrains init against the array length when the limit is a constant or an invariant local — when HasArrayLengthLimit is true, ident is set to the limit (loopcloning.cpp ~line 1419) and no bound on init is emitted, so the fast clone (with bounds checks removed) is taken even when init is far outside the indexed array.

              This is benign in the very common shape for (i = a.Length - 1; i > 0; i--) a[i]… because there init and the indexed array agree and the array-deref/length condition implicitly bounds init. It becomes unsafe as soon as the loop test's limit array differs from the array being indexed inside the body.

              Reproduction

              Repository: dotnet/runtime
              Tested on: main at 9593745 (also reproduces on .NET 10 RTM)
              Configuration: windows.x64.Checked, FullOpts, tiering disabled.

              usingSystem;usingSystem.Runtime.CompilerServices;publicclassP{[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGT(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>limitArr.Length;i--)sum+=accessArr[i];returnsum;}[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGE(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>=limitArr.Length;i--)sum+=accessArr[i];returnsum;}publicstaticintMain(){Console.WriteLine("=== GT ===");try{intx=LatentBugGT(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}Console.WriteLine("=== GE ===");try{intx=LatentBugGE(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}return100;}}

              Expected

              Both calls index accessArr[1000] (length 5) and must throw IndexOutOfRangeException.

              Actual

              === GT ===
              BUG: returned 0
              === GE ===
              BUG: returned 0
              

              Both loops silently iterate i = 1000, 999, …, 2 (or …, 1 for GE) and dereference accessArr[i] for ~999 out-of-bounds indices with no exception. Whatever the GC heap happens to contain at those offsets is summed and returned. (In the small repro above the sum happens to be 0; with arbitrary heap state this would silently corrupt analysis results, or — for write loops — silently corrupt heap memory.)

              Cloning conditions emitted

              JIT dump from the unmodified clrjit.dll:

              Conditions: (V04 GE 0) && (V00.Length LT V01.Length)
              Considering condition 0: (V04 GE 0), could not be evaluated
              Considering condition 1: (V00.Length LT V01.Length), could not be evaluated
              Loops cloned: 1
              Loops statically optimized: 0
              

              V04 = startIdx, V00 = limitArr, V01 = accessArr. With the bad inputs startIdx=1000, limitArr.Length=1, accessArr.Length=5:

              conditionactual
              startIdx >= 01000 >= 0
              limitArr.Length < accessArr.Length1 < 5

              Both pass → fast clone runs without bounds checks → OOB reads of accessArr.

              Root cause

              In src/coreclr/jit/loopcloning.cppoptDeriveLoopCloningConditions:

              • For HasConstInit decreasing loops, ident = init (line ~1348).
              • For variable-init decreasing loops, ident = initVar (line ~1370).
              • For HasArrayLengthLimit, ident is unconditionally set to the limit array's length (~line 1419), regardless of direction. The decreasing path then emits cond(opLimitCondition, ident, accessArr.Length) which evaluates to limitArr.Length < accessArr.Length — a constraint on limit, not init.

              The cloning condition needed for soundness in the decreasing direction is init < accessArr.Length (for GT) or init <= accessArr.Length (for GE), independent of what the limit array is.

              Suggested fix

              For decreasing loops with HasArrayLengthLimit, do not let the limit array length be used as ident. Instead, always materialize an independent init-based identifier (from iterInfo->IterVar at the preheader, or iterInfo->ConstInitValue) and emit the per-access init <relop> accessArr.Length condition against it. The increasing-loop path can be left as is.

              Why this isn't fixed in PR for #84697

              This was found while reviewing the changes for #84697 (bounds-check elimination for i != arr.Length loops). To keep that PR's blast radius bounded I restricted GT_NE support to increasing loops only, with an explicit comment noting that decreasing GT_NE would have inherited (and widened) this bug into the very common for (i = end; i != start; i--) idiom. The regression test in that PR (Runtime_84697.SumDecreasingNETwoArrays) pins the safe behavior for GT_NE. The pre-existing GT_GT/GT_GE versions documented here remain.

              Suggested area / labels

              • area-CodeGen-coreclr
              • tenet-correctness
              • bug

              category

              correctness

              theme

              loop-cloning · bounds-checks

              skill-level

              expert

              cost

              medium

              impact

              medium (correctness — silent OOB read in an uncommon but legitimate code shape)

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

                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)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ · Issue #129176 · dotnet/runtime · GitHub
                  Skip to content

                  JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ #129176

                  Description

                  @AndyAyersMS

                  JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ

                  Note

                  This issue was investigated and drafted with GitHub Copilot CLI.

                  Description

                  optDeriveLoopCloningConditions derives a limit <relop> arr.Length condition for the per-access bounds-check elision, where limit is the loop test's RHS. For increasing loops this is sound because the visited indices are [init, limit) and init is independently constrained >= 0. For decreasing loops the visited indices are [limit+1, init], so the value that must fit in the indexed array is init, not limit. The current code only constrains init against the array length when the limit is a constant or an invariant local — when HasArrayLengthLimit is true, ident is set to the limit (loopcloning.cpp ~line 1419) and no bound on init is emitted, so the fast clone (with bounds checks removed) is taken even when init is far outside the indexed array.

                  This is benign in the very common shape for (i = a.Length - 1; i > 0; i--) a[i]… because there init and the indexed array agree and the array-deref/length condition implicitly bounds init. It becomes unsafe as soon as the loop test's limit array differs from the array being indexed inside the body.

                  Reproduction

                  Repository: dotnet/runtime
                  Tested on: main at 9593745 (also reproduces on .NET 10 RTM)
                  Configuration: windows.x64.Checked, FullOpts, tiering disabled.

                  usingSystem;usingSystem.Runtime.CompilerServices;publicclassP{[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGT(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>limitArr.Length;i--)sum+=accessArr[i];returnsum;}[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGE(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>=limitArr.Length;i--)sum+=accessArr[i];returnsum;}publicstaticintMain(){Console.WriteLine("=== GT ===");try{intx=LatentBugGT(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}Console.WriteLine("=== GE ===");try{intx=LatentBugGE(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}return100;}}

                  Expected

                  Both calls index accessArr[1000] (length 5) and must throw IndexOutOfRangeException.

                  Actual

                  === GT ===
                  BUG: returned 0
                  === GE ===
                  BUG: returned 0
                  

                  Both loops silently iterate i = 1000, 999, …, 2 (or …, 1 for GE) and dereference accessArr[i] for ~999 out-of-bounds indices with no exception. Whatever the GC heap happens to contain at those offsets is summed and returned. (In the small repro above the sum happens to be 0; with arbitrary heap state this would silently corrupt analysis results, or — for write loops — silently corrupt heap memory.)

                  Cloning conditions emitted

                  JIT dump from the unmodified clrjit.dll:

                  Conditions: (V04 GE 0) && (V00.Length LT V01.Length)
                  Considering condition 0: (V04 GE 0), could not be evaluated
                  Considering condition 1: (V00.Length LT V01.Length), could not be evaluated
                  Loops cloned: 1
                  Loops statically optimized: 0
                  

                  V04 = startIdx, V00 = limitArr, V01 = accessArr. With the bad inputs startIdx=1000, limitArr.Length=1, accessArr.Length=5:

                  conditionactual
                  startIdx >= 01000 >= 0
                  limitArr.Length < accessArr.Length1 < 5

                  Both pass → fast clone runs without bounds checks → OOB reads of accessArr.

                  Root cause

                  In src/coreclr/jit/loopcloning.cppoptDeriveLoopCloningConditions:

                  • For HasConstInit decreasing loops, ident = init (line ~1348).
                  • For variable-init decreasing loops, ident = initVar (line ~1370).
                  • For HasArrayLengthLimit, ident is unconditionally set to the limit array's length (~line 1419), regardless of direction. The decreasing path then emits cond(opLimitCondition, ident, accessArr.Length) which evaluates to limitArr.Length < accessArr.Length — a constraint on limit, not init.

                  The cloning condition needed for soundness in the decreasing direction is init < accessArr.Length (for GT) or init <= accessArr.Length (for GE), independent of what the limit array is.

                  Suggested fix

                  For decreasing loops with HasArrayLengthLimit, do not let the limit array length be used as ident. Instead, always materialize an independent init-based identifier (from iterInfo->IterVar at the preheader, or iterInfo->ConstInitValue) and emit the per-access init <relop> accessArr.Length condition against it. The increasing-loop path can be left as is.

                  Why this isn't fixed in PR for #84697

                  This was found while reviewing the changes for #84697 (bounds-check elimination for i != arr.Length loops). To keep that PR's blast radius bounded I restricted GT_NE support to increasing loops only, with an explicit comment noting that decreasing GT_NE would have inherited (and widened) this bug into the very common for (i = end; i != start; i--) idiom. The regression test in that PR (Runtime_84697.SumDecreasingNETwoArrays) pins the safe behavior for GT_NE. The pre-existing GT_GT/GT_GE versions documented here remain.

                  Suggested area / labels

                  • area-CodeGen-coreclr
                  • tenet-correctness
                  • bug

                  category

                  correctness

                  theme

                  loop-cloning · bounds-checks

                  skill-level

                  expert

                  cost

                  medium

                  impact

                  medium (correctness — silent OOB read in an uncommon but legitimate code shape)

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

                    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)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ · Issue #129176 · dotnet/runtime · GitHub
                      Skip to content

                      JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ #129176

                      Description

                      @AndyAyersMS

                      JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ

                      Note

                      This issue was investigated and drafted with GitHub Copilot CLI.

                      Description

                      optDeriveLoopCloningConditions derives a limit <relop> arr.Length condition for the per-access bounds-check elision, where limit is the loop test's RHS. For increasing loops this is sound because the visited indices are [init, limit) and init is independently constrained >= 0. For decreasing loops the visited indices are [limit+1, init], so the value that must fit in the indexed array is init, not limit. The current code only constrains init against the array length when the limit is a constant or an invariant local — when HasArrayLengthLimit is true, ident is set to the limit (loopcloning.cpp ~line 1419) and no bound on init is emitted, so the fast clone (with bounds checks removed) is taken even when init is far outside the indexed array.

                      This is benign in the very common shape for (i = a.Length - 1; i > 0; i--) a[i]… because there init and the indexed array agree and the array-deref/length condition implicitly bounds init. It becomes unsafe as soon as the loop test's limit array differs from the array being indexed inside the body.

                      Reproduction

                      Repository: dotnet/runtime
                      Tested on: main at 9593745 (also reproduces on .NET 10 RTM)
                      Configuration: windows.x64.Checked, FullOpts, tiering disabled.

                      usingSystem;usingSystem.Runtime.CompilerServices;publicclassP{[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGT(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>limitArr.Length;i--)sum+=accessArr[i];returnsum;}[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGE(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>=limitArr.Length;i--)sum+=accessArr[i];returnsum;}publicstaticintMain(){Console.WriteLine("=== GT ===");try{intx=LatentBugGT(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}Console.WriteLine("=== GE ===");try{intx=LatentBugGE(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}return100;}}

                      Expected

                      Both calls index accessArr[1000] (length 5) and must throw IndexOutOfRangeException.

                      Actual

                      === GT ===
                      BUG: returned 0
                      === GE ===
                      BUG: returned 0
                      

                      Both loops silently iterate i = 1000, 999, …, 2 (or …, 1 for GE) and dereference accessArr[i] for ~999 out-of-bounds indices with no exception. Whatever the GC heap happens to contain at those offsets is summed and returned. (In the small repro above the sum happens to be 0; with arbitrary heap state this would silently corrupt analysis results, or — for write loops — silently corrupt heap memory.)

                      Cloning conditions emitted

                      JIT dump from the unmodified clrjit.dll:

                      Conditions: (V04 GE 0) && (V00.Length LT V01.Length)
                      Considering condition 0: (V04 GE 0), could not be evaluated
                      Considering condition 1: (V00.Length LT V01.Length), could not be evaluated
                      Loops cloned: 1
                      Loops statically optimized: 0
                      

                      V04 = startIdx, V00 = limitArr, V01 = accessArr. With the bad inputs startIdx=1000, limitArr.Length=1, accessArr.Length=5:

                      conditionactual
                      startIdx >= 01000 >= 0
                      limitArr.Length < accessArr.Length1 < 5

                      Both pass → fast clone runs without bounds checks → OOB reads of accessArr.

                      Root cause

                      In src/coreclr/jit/loopcloning.cppoptDeriveLoopCloningConditions:

                      • For HasConstInit decreasing loops, ident = init (line ~1348).
                      • For variable-init decreasing loops, ident = initVar (line ~1370).
                      • For HasArrayLengthLimit, ident is unconditionally set to the limit array's length (~line 1419), regardless of direction. The decreasing path then emits cond(opLimitCondition, ident, accessArr.Length) which evaluates to limitArr.Length < accessArr.Length — a constraint on limit, not init.

                      The cloning condition needed for soundness in the decreasing direction is init < accessArr.Length (for GT) or init <= accessArr.Length (for GE), independent of what the limit array is.

                      Suggested fix

                      For decreasing loops with HasArrayLengthLimit, do not let the limit array length be used as ident. Instead, always materialize an independent init-based identifier (from iterInfo->IterVar at the preheader, or iterInfo->ConstInitValue) and emit the per-access init <relop> accessArr.Length condition against it. The increasing-loop path can be left as is.

                      Why this isn't fixed in PR for #84697

                      This was found while reviewing the changes for #84697 (bounds-check elimination for i != arr.Length loops). To keep that PR's blast radius bounded I restricted GT_NE support to increasing loops only, with an explicit comment noting that decreasing GT_NE would have inherited (and widened) this bug into the very common for (i = end; i != start; i--) idiom. The regression test in that PR (Runtime_84697.SumDecreasingNETwoArrays) pins the safe behavior for GT_NE. The pre-existing GT_GT/GT_GE versions documented here remain.

                      Suggested area / labels

                      • area-CodeGen-coreclr
                      • tenet-correctness
                      • bug

                      category

                      correctness

                      theme

                      loop-cloning · bounds-checks

                      skill-level

                      expert

                      cost

                      medium

                      impact

                      medium (correctness — silent OOB read in an uncommon but legitimate code shape)

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

                        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)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ · Issue #129176 · dotnet/runtime · GitHub
                          Skip to content

                          JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ #129176

                          Description

                          @AndyAyersMS

                          JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ

                          Note

                          This issue was investigated and drafted with GitHub Copilot CLI.

                          Description

                          optDeriveLoopCloningConditions derives a limit <relop> arr.Length condition for the per-access bounds-check elision, where limit is the loop test's RHS. For increasing loops this is sound because the visited indices are [init, limit) and init is independently constrained >= 0. For decreasing loops the visited indices are [limit+1, init], so the value that must fit in the indexed array is init, not limit. The current code only constrains init against the array length when the limit is a constant or an invariant local — when HasArrayLengthLimit is true, ident is set to the limit (loopcloning.cpp ~line 1419) and no bound on init is emitted, so the fast clone (with bounds checks removed) is taken even when init is far outside the indexed array.

                          This is benign in the very common shape for (i = a.Length - 1; i > 0; i--) a[i]… because there init and the indexed array agree and the array-deref/length condition implicitly bounds init. It becomes unsafe as soon as the loop test's limit array differs from the array being indexed inside the body.

                          Reproduction

                          Repository: dotnet/runtime
                          Tested on: main at 9593745 (also reproduces on .NET 10 RTM)
                          Configuration: windows.x64.Checked, FullOpts, tiering disabled.

                          usingSystem;usingSystem.Runtime.CompilerServices;publicclassP{[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGT(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>limitArr.Length;i--)sum+=accessArr[i];returnsum;}[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGE(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>=limitArr.Length;i--)sum+=accessArr[i];returnsum;}publicstaticintMain(){Console.WriteLine("=== GT ===");try{intx=LatentBugGT(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}Console.WriteLine("=== GE ===");try{intx=LatentBugGE(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}return100;}}

                          Expected

                          Both calls index accessArr[1000] (length 5) and must throw IndexOutOfRangeException.

                          Actual

                          === GT ===
                          BUG: returned 0
                          === GE ===
                          BUG: returned 0
                          

                          Both loops silently iterate i = 1000, 999, …, 2 (or …, 1 for GE) and dereference accessArr[i] for ~999 out-of-bounds indices with no exception. Whatever the GC heap happens to contain at those offsets is summed and returned. (In the small repro above the sum happens to be 0; with arbitrary heap state this would silently corrupt analysis results, or — for write loops — silently corrupt heap memory.)

                          Cloning conditions emitted

                          JIT dump from the unmodified clrjit.dll:

                          Conditions: (V04 GE 0) && (V00.Length LT V01.Length)
                          Considering condition 0: (V04 GE 0), could not be evaluated
                          Considering condition 1: (V00.Length LT V01.Length), could not be evaluated
                          Loops cloned: 1
                          Loops statically optimized: 0
                          

                          V04 = startIdx, V00 = limitArr, V01 = accessArr. With the bad inputs startIdx=1000, limitArr.Length=1, accessArr.Length=5:

                          conditionactual
                          startIdx >= 01000 >= 0
                          limitArr.Length < accessArr.Length1 < 5

                          Both pass → fast clone runs without bounds checks → OOB reads of accessArr.

                          Root cause

                          In src/coreclr/jit/loopcloning.cppoptDeriveLoopCloningConditions:

                          • For HasConstInit decreasing loops, ident = init (line ~1348).
                          • For variable-init decreasing loops, ident = initVar (line ~1370).
                          • For HasArrayLengthLimit, ident is unconditionally set to the limit array's length (~line 1419), regardless of direction. The decreasing path then emits cond(opLimitCondition, ident, accessArr.Length) which evaluates to limitArr.Length < accessArr.Length — a constraint on limit, not init.

                          The cloning condition needed for soundness in the decreasing direction is init < accessArr.Length (for GT) or init <= accessArr.Length (for GE), independent of what the limit array is.

                          Suggested fix

                          For decreasing loops with HasArrayLengthLimit, do not let the limit array length be used as ident. Instead, always materialize an independent init-based identifier (from iterInfo->IterVar at the preheader, or iterInfo->ConstInitValue) and emit the per-access init <relop> accessArr.Length condition against it. The increasing-loop path can be left as is.

                          Why this isn't fixed in PR for #84697

                          This was found while reviewing the changes for #84697 (bounds-check elimination for i != arr.Length loops). To keep that PR's blast radius bounded I restricted GT_NE support to increasing loops only, with an explicit comment noting that decreasing GT_NE would have inherited (and widened) this bug into the very common for (i = end; i != start; i--) idiom. The regression test in that PR (Runtime_84697.SumDecreasingNETwoArrays) pins the safe behavior for GT_NE. The pre-existing GT_GT/GT_GE versions documented here remain.

                          Suggested area / labels

                          • area-CodeGen-coreclr
                          • tenet-correctness
                          • bug

                          category

                          correctness

                          theme

                          loop-cloning · bounds-checks

                          skill-level

                          expert

                          cost

                          medium

                          impact

                          medium (correctness — silent OOB read in an uncommon but legitimate code shape)

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

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

                              JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ #129176

                              Description

                              @AndyAyersMS

                              JIT: loop cloning generates unsafe fast clone for decreasing loops when limit and indexed arrays differ

                              Note

                              This issue was investigated and drafted with GitHub Copilot CLI.

                              Description

                              optDeriveLoopCloningConditions derives a limit <relop> arr.Length condition for the per-access bounds-check elision, where limit is the loop test's RHS. For increasing loops this is sound because the visited indices are [init, limit) and init is independently constrained >= 0. For decreasing loops the visited indices are [limit+1, init], so the value that must fit in the indexed array is init, not limit. The current code only constrains init against the array length when the limit is a constant or an invariant local — when HasArrayLengthLimit is true, ident is set to the limit (loopcloning.cpp ~line 1419) and no bound on init is emitted, so the fast clone (with bounds checks removed) is taken even when init is far outside the indexed array.

                              This is benign in the very common shape for (i = a.Length - 1; i > 0; i--) a[i]… because there init and the indexed array agree and the array-deref/length condition implicitly bounds init. It becomes unsafe as soon as the loop test's limit array differs from the array being indexed inside the body.

                              Reproduction

                              Repository: dotnet/runtime
                              Tested on: main at 9593745 (also reproduces on .NET 10 RTM)
                              Configuration: windows.x64.Checked, FullOpts, tiering disabled.

                              usingSystem;usingSystem.Runtime.CompilerServices;publicclassP{[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGT(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>limitArr.Length;i--)sum+=accessArr[i];returnsum;}[MethodImpl(MethodImplOptions.NoInlining)]publicstaticintLatentBugGE(byte[]limitArr,byte[]accessArr,intstartIdx){intsum=0;for(inti=startIdx;i>=limitArr.Length;i--)sum+=accessArr[i];returnsum;}publicstaticintMain(){Console.WriteLine("=== GT ===");try{intx=LatentBugGT(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}Console.WriteLine("=== GE ===");try{intx=LatentBugGE(newbyte[1],newbyte[5],1000);Console.WriteLine($"BUG: returned {x}");}catch(IndexOutOfRangeException){Console.WriteLine("OK: threw IndexOutOfRangeException");}return100;}}

                              Expected

                              Both calls index accessArr[1000] (length 5) and must throw IndexOutOfRangeException.

                              Actual

                              === GT ===
                              BUG: returned 0
                              === GE ===
                              BUG: returned 0
                              

                              Both loops silently iterate i = 1000, 999, …, 2 (or …, 1 for GE) and dereference accessArr[i] for ~999 out-of-bounds indices with no exception. Whatever the GC heap happens to contain at those offsets is summed and returned. (In the small repro above the sum happens to be 0; with arbitrary heap state this would silently corrupt analysis results, or — for write loops — silently corrupt heap memory.)

                              Cloning conditions emitted

                              JIT dump from the unmodified clrjit.dll:

                              Conditions: (V04 GE 0) && (V00.Length LT V01.Length)
                              Considering condition 0: (V04 GE 0), could not be evaluated
                              Considering condition 1: (V00.Length LT V01.Length), could not be evaluated
                              Loops cloned: 1
                              Loops statically optimized: 0
                              

                              V04 = startIdx, V00 = limitArr, V01 = accessArr. With the bad inputs startIdx=1000, limitArr.Length=1, accessArr.Length=5:

                              conditionactual
                              startIdx >= 01000 >= 0
                              limitArr.Length < accessArr.Length1 < 5

                              Both pass → fast clone runs without bounds checks → OOB reads of accessArr.

                              Root cause

                              In src/coreclr/jit/loopcloning.cppoptDeriveLoopCloningConditions:

                              • For HasConstInit decreasing loops, ident = init (line ~1348).
                              • For variable-init decreasing loops, ident = initVar (line ~1370).
                              • For HasArrayLengthLimit, ident is unconditionally set to the limit array's length (~line 1419), regardless of direction. The decreasing path then emits cond(opLimitCondition, ident, accessArr.Length) which evaluates to limitArr.Length < accessArr.Length — a constraint on limit, not init.

                              The cloning condition needed for soundness in the decreasing direction is init < accessArr.Length (for GT) or init <= accessArr.Length (for GE), independent of what the limit array is.

                              Suggested fix

                              For decreasing loops with HasArrayLengthLimit, do not let the limit array length be used as ident. Instead, always materialize an independent init-based identifier (from iterInfo->IterVar at the preheader, or iterInfo->ConstInitValue) and emit the per-access init <relop> accessArr.Length condition against it. The increasing-loop path can be left as is.

                              Why this isn't fixed in PR for #84697

                              This was found while reviewing the changes for #84697 (bounds-check elimination for i != arr.Length loops). To keep that PR's blast radius bounded I restricted GT_NE support to increasing loops only, with an explicit comment noting that decreasing GT_NE would have inherited (and widened) this bug into the very common for (i = end; i != start; i--) idiom. The regression test in that PR (Runtime_84697.SumDecreasingNETwoArrays) pins the safe behavior for GT_NE. The pre-existing GT_GT/GT_GE versions documented here remain.

                              Suggested area / labels

                              • area-CodeGen-coreclr
                              • tenet-correctness
                              • bug

                              category

                              correctness

                              theme

                              loop-cloning · bounds-checks

                              skill-level

                              expert

                              cost

                              medium

                              impact

                              medium (correctness — silent OOB read in an uncommon but legitimate code shape)

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions