Skip to content

Commit 71eb9a3

Browse files
authored
Merge pull request #373 from grosser/grosser/hmac
enable preventing pipe injection
2 parents fa1cc25 + 1fdf79a commit 71eb9a3

5 files changed

Lines changed: 192 additions & 7 deletions

File tree

.rubocop.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Style/EmptyElse:
6363
Enabled: false
6464

6565
RSpec/DescribedClass:
66-
EnforcedStyle: explicit
66+
Enabled: false # does not work for nested classes
6767

6868
Style/DoubleNegation:
6969
Enabled: false

Readme.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,22 @@ Thread.new { loop { queue << rand(100); sleep 2 } } # job producer
189189
Parallel.map(Proc.new { queue.pop }, in_processes: 3) { |f| f ? puts("#{f} received") : sleep(1) }
190190
```
191191

192+
### Processes vs Wire serializer: security risk for hardened environments
193+
194+
Worker processes talk to the parent over an anonymous pipe using `Marshal` by default.
195+
196+
If you've hardened your host against `ptrace`/`/proc/<pid>/mem` access (e.g. `ptrace_scope >= 2`) and
197+
want to also close the `/proc/<pid>/fd/<n>` pipe-reopen vector, use the HMAC serializer.
198+
199+
It length-prefixes and HMAC-SHA256 signs each message with a per-worker secret generated before `fork`,
200+
so a same-UID attacker that reopens the pipe can't inject a forged `Marshal` payload into the parent (which would be RCE).
201+
202+
Raises `SecurityError` on mismatch (not a `StandardError`).
203+
204+
```ruby
205+
Parallel.map(items, in_processes: 2, serializer: Parallel::Serializer::Hmac.new) { ... }
206+
```
207+
192208
Tips
193209
====
194210

lib/parallel.rb

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22
require 'rbconfig'
33
require 'parallel/version'
4+
require 'parallel/serializer'
45

56
module Parallel
67
Stop = Object.new.freeze
@@ -63,10 +64,11 @@ class Worker
6364
attr_reader :pid, :read, :write
6465
attr_accessor :thread
6566

66-
def initialize(read, write, pid)
67+
def initialize(read, write, pid, serializer)
6768
@read = read
6869
@write = write
6970
@pid = pid
71+
@serializer = serializer
7072
end
7173

7274
def stop
@@ -83,13 +85,13 @@ def close_pipes
8385

8486
def work(data)
8587
begin
86-
Marshal.dump(data, write)
88+
@serializer.dump(data, write)
8789
rescue Errno::EPIPE
8890
raise DeadWorker
8991
end
9092

9193
result = begin
92-
Marshal.load(read)
94+
@serializer.load(read)
9395
rescue EOFError
9496
raise DeadWorker
9597
end
@@ -622,6 +624,7 @@ def create_workers(job_factory, options, &block)
622624
def worker(job_factory, options, &block)
623625
child_read, parent_write = IO.pipe
624626
parent_read, child_write = IO.pipe
627+
options[:serializer] ||= Serializer::Marshal
625628

626629
pid = Process.fork do
627630
self.worker_number = options[:worker_number]
@@ -642,12 +645,13 @@ def worker(job_factory, options, &block)
642645
child_read.close
643646
child_write.close
644647

645-
Worker.new(parent_read, parent_write, pid)
648+
Worker.new(parent_read, parent_write, pid, options[:serializer])
646649
end
647650

648651
def process_incoming_jobs(read, write, job_factory, options, &block)
652+
serializer = options.fetch(:serializer)
649653
until read.eof?
650-
data = Marshal.load(read)
654+
data = serializer.load(read)
651655
item, index = job_factory.unpack(data)
652656

653657
result =
@@ -661,7 +665,7 @@ def process_incoming_jobs(read, write, job_factory, options, &block)
661665
end
662666

663667
begin
664-
Marshal.dump(result, write)
668+
serializer.dump(result, write)
665669
rescue Errno::EPIPE
666670
return # parent thread already dead
667671
end

lib/parallel/serializer.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
require 'openssl'
3+
require 'securerandom'
4+
5+
module Parallel
6+
# Pluggable wire serializers. Each must respond to `dump(data, io)` /
7+
# `load(io)` (used directly by Worker) and `dump(data)` / `load(string)`
8+
# (used by wrappers like Hmac).
9+
module Serializer
10+
# Raw Marshal. Fast but trusts anything written to the pipe — a same-UID
11+
# attacker that reopens /proc/<pid>/fd/<n> can inject Marshal gadgets (RCE).
12+
Marshal = ::Marshal
13+
14+
# Wraps any inner serializer with a length-prefixed HMAC-SHA256 frame keyed
15+
# on a per-worker secret generated before fork. Forged frames from a
16+
# pipe-injector fail verification.
17+
class Hmac
18+
LENGTH_FORMAT = 'N' # 32-bit big-endian unsigned int
19+
LENGTH_BYTES = 4
20+
MAC_BYTES = 32 # SHA256
21+
22+
def initialize(inner: Marshal, secret: SecureRandom.bytes(32))
23+
@inner = inner
24+
@secret = secret
25+
end
26+
27+
def dump(data, io)
28+
payload = @inner.dump(data)
29+
mac = OpenSSL::HMAC.digest('SHA256', @secret, payload)
30+
io.write([payload.bytesize].pack(LENGTH_FORMAT), mac, payload)
31+
end
32+
33+
def load(io)
34+
# nil at frame boundary = clean EOF (worker died / pipe closed between messages)
35+
header = io.read(LENGTH_BYTES) || raise(EOFError) # eof stops worker
36+
raise SecurityError, "truncated frame header" if header.bytesize != LENGTH_BYTES
37+
38+
length = header.unpack1(LENGTH_FORMAT)
39+
mac = io.read(MAC_BYTES)
40+
raise SecurityError, "truncated frame mac" if mac.nil? || mac.bytesize != MAC_BYTES
41+
42+
payload = io.read(length)
43+
raise SecurityError, "truncated frame payload" if payload.nil? || payload.bytesize != length
44+
45+
expected = OpenSSL::HMAC.digest('SHA256', @secret, payload)
46+
raise SecurityError, "HMAC mismatch on worker pipe" unless OpenSSL.fixed_length_secure_compare(mac, expected)
47+
48+
@inner.load(payload)
49+
end
50+
end
51+
end
52+
end

spec/parallel/serializer_spec.rb

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# frozen_string_literal: true
2+
require 'spec_helper'
3+
require 'json'
4+
5+
describe Parallel::Serializer do
6+
describe Parallel::Serializer::Hmac do
7+
let(:serializer) { described_class.new }
8+
9+
def with_pipe
10+
read, write = IO.pipe
11+
yield read, write
12+
ensure
13+
read.close unless read.closed?
14+
write.close unless write.closed?
15+
end
16+
17+
def pipe_round_trip(serializer, data)
18+
with_pipe do |read, write|
19+
serializer.dump(data, write)
20+
write.close
21+
return serializer.load(read)
22+
end
23+
end
24+
25+
it "round-trips a simple value" do
26+
pipe_round_trip(serializer, "hello").should == "hello"
27+
end
28+
29+
it "round-trips a complex value" do
30+
data = { a: [1, 2, 3], b: { c: "x" }, d: Set.new([1, 2]) }
31+
pipe_round_trip(serializer, data).should == data
32+
end
33+
34+
it "round-trips multiple messages" do
35+
with_pipe do |read, write|
36+
[1, "two", [3, 4], { five: 5 }].each { |m| serializer.dump(m, write) }
37+
write.close
38+
[1, "two", [3, 4], { five: 5 }].each do |expected|
39+
serializer.load(read).should == expected
40+
end
41+
read.eof?.should == true
42+
end
43+
end
44+
45+
it "rejects payloads signed with a different secret" do
46+
with_pipe do |read, write|
47+
described_class.new.dump("HACKERMAN", write)
48+
write.close
49+
-> { serializer.load(read) }.should raise_error(SecurityError, /HMAC mismatch/)
50+
end
51+
end
52+
53+
it "rejects payloads with a tampered body" do
54+
frame = with_pipe do |read, write|
55+
serializer.dump("untampered", write)
56+
write.close
57+
read.read
58+
end
59+
tampered = frame.dup
60+
tampered[-1] = (tampered[-1].ord ^ 0x01).chr
61+
62+
with_pipe do |read, write|
63+
write.write(tampered)
64+
write.close
65+
-> { serializer.load(read) }.should raise_error(SecurityError, /HMAC mismatch/)
66+
end
67+
end
68+
69+
it "raises SecurityError on a truncated frame" do
70+
frame = with_pipe do |read, write|
71+
serializer.dump("whatever", write)
72+
write.close
73+
read.read
74+
end
75+
76+
with_pipe do |read, write|
77+
write.write(frame[0, frame.bytesize - 5]) # drop last 5 bytes of payload
78+
write.close
79+
-> { serializer.load(read) }.should raise_error(SecurityError, /truncated frame/)
80+
end
81+
end
82+
83+
it "raises EOFError on a cleanly closed empty pipe (worker death, not tampering)" do
84+
with_pipe do |read, write|
85+
write.close
86+
-> { serializer.load(read) }.should raise_error(EOFError)
87+
end
88+
end
89+
90+
it "works end-to-end" do
91+
items = (1..20).to_a
92+
result = Parallel.map(items, in_processes: 3, serializer: serializer) { |i| i * 10 }
93+
result.should == items.map { |i| i * 10 }
94+
end
95+
96+
it "propagates worker exceptions across the HMAC frame" do
97+
lambda {
98+
Parallel.map([1, 2, 3], in_processes: 2, serializer: serializer) { |i| raise "boom-#{i}" } # rubocop:disable Lint/UnreachableLoop
99+
}.should raise_error(RuntimeError, /boom-\d/)
100+
end
101+
102+
it "round-trips large payloads (bigger than a pipe buffer)" do
103+
size = 200_000 # > typical 64KiB pipe buffer
104+
big = "x" * size
105+
result = Parallel.map([1, 2, 3], in_processes: 2, serializer: serializer) { |i| [i, big] }
106+
result.map { |i, s| [i, s == big] }.should == [[1, true], [2, true], [3, true]]
107+
end
108+
109+
it "supports a custom inner serializer" do
110+
pipe_round_trip(described_class.new(inner: JSON), [1, :a, 3]).should == [1, "a", 3]
111+
end
112+
end
113+
end

0 commit comments

Comments
 (0)