Jakub Ciolek - cloud security, system engineering and compilers

6 min read Original article ↗

Early in March, I reported two compiler bugs affecting Go releases up to 1.26.1 which broke the Go memory safety guarantees using only safe Go code.

This means the proof-of-concepts did not import unsafe, did not use CGO and did not rely on custom assembly nor data races. Using specially constructed code, I was able to turn both bugs into control-flow hijack, and with the loop bug I also got execution of injected instructions.

I’m not including the full end-to-end exploits, to allow the fixed releases to become more widely available. I’ll briefly describe the issues and show the problematic code patterns though. An interesting learning from this, perhaps obvious to some people, is that memory safety is a property of the whole toolchain, not only the language itself.

I found the bugs after I decided to have a closer look at the compiler. I had reported several denial-of-service issues in the standard library before. I have spent a few years contributing smaller patches to the compiler on-and-off so it was a codebase I understood reasonably well. In late November I found two errors in the devel version of the prove optimization pass (the step that infers limits and “proves” facts about SSA values, among others to perform bounds check elimination).

If you exclude the generated rewrite tables, prove is basically the second largest backend SSA optimization pass, behind only the register allocator itself. It also deals with arithmetic and signedness, two easy ways to shoot yourself in the foot. It turned out, the hunch was correct as there were more problems lurking in that area. This time in the release versions of Go.

Bug 1: a loop that wrapped and the compiler swore it didn’t

CVE-2026-27143

The first problem existed in the prove/loopbce reasoning about induction variables and bound checks.

The triggering shape is surprisingly simple:

for i := int8(0); i <= int8(120); i += int8(10) {
    arr[i] = value
}

Question to you: if i == 120 and you perform the iteration, then add 10 to it, what will you get? What’s the answer? 130? It turns out the answer is -126. int8 has eight bits, it wraps.

That’s literally the whole bug. The compiler believed something different though.

If you make prove emit debug data, these are the facts it inferred:

  • Induction variable: limits [0,120], increment 10
  • Proved IsInBounds

Once it takes those facts, it will confidently remove bound checking for array access if the array size fits within the induction variable limits. In this case, the index can become negative. With correct arrangement of surrounding code, we can make the program jump to an arbitrary address, change control flow or even inject instructions.

I must admit I could not believe this at first and reran the program many times on different machines and different versions of the compiler. Vulnerability hunting gives a large thrill when you finally confirm something is a real security bug. When I found it, I was really ecstatic, as bad as it sounds. That feeling would change soon, but more about that later.

Bug 2: a no-op conversion that changed nothing and broke everything

CVE-2026-27144

The second problem lived in another place, slightly later, in the SSA lowering phase. The compiler knows to be careful when copying from one part of an array into an overlapping nearby part. This is important for safety. It turns out, if you wrapped the source into a conversion that didn’t really change anything, the compiler forgot to do that.

type T [N][]uint64

// buggy shape
*p = T(*q)

// control shape
*p = *q

Those two lines should mean the same thing here. The conversion does not really matter, the overlap does. p and q can point at partially overlapping windows of the same underlying array. Once that is true, the compiler should use the careful copy path. If it does not do that, it can overwrite data it has not read yet. If you do that over simple data, you get a wrong result. If you do that over more complex composite values it gets more interesting. If you do that to slice values, later code still trusts the result. A harmless-looking assignment then turns into memory corruption.

Two distinct problems, but really the same cause

Those two bugs resided in two different parts of the compiler, but the root cause was the same. In the first case, the compiler erased the possibility of signed wrap. In the second one, it excluded the possibility of overlap.

Those were two different mechanisms of failure, but both caused by the same problem: counterfeit certainty. I guess that’s how compilers break, you can write some compiler code and it looks reasonable, there’s math and tests and everything seems correct. Things get reviewed by multiple people and merged, but it turns out you may accidentally upgrade “probably safe” into “proved safe” way too soon.

Then, once the compiler starts to generate and optimize code based on that promise, it will miscompile. Sometimes, like in those cases, you can make the code dance around it and break security boundaries.

Then git blame got personal

As I was wrapping up the email to the Go security team, I sat down to write the last part of the advisory. I was still ecstatic about the find. The final part was to figure out when this was introduced. I did the routine thing and ran git blame.

It came back with my own name. That was not ideal and I did not like it. I got a sinking feeling in my stomach and went from feeling really smart to feeling really dumb, real fast.

It turns out that the underlying issue behind the first bug came from a CL I landed more than three years earlier.

I got over it by the next day and now see it as an amusing story, but it was quite an experience.

Disclosure

I reported both issues back in March.

I must commend the Go security team as they are always excellent. Neal got back to me within 3 minutes of the first report and within 4 minutes of the second report. Mind you, those were sent days apart. Typically, the Go security advisories take up to a week to get a response. I guess seeing “memory corruption” and “compiler bug” in the same email thread speeds the process up.

I’ll publish the full minimized reproducers, deeper technical dive and more once the fixed releases become more broadly available.

Learnings

The main learning for myself is that a memory-safe language is only as safe as the entire toolchain enforcing its invariants. Frontend, optimizer, lowering, runtime and code generation. All of those parts sit within the trust boundary. If parts of it start certifying wrong proofs and generating code on top of that, the source code can stay safe while the compiled program stops being memory-safe.

Every optimization is a security claim and most of the time those claims are true. In this case, those two were not.