├── .gitignore ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── examples ├── arp_all.rb └── udp_dump.rb ├── extra ├── vlink.rb ├── vlink_parse.rb └── vtap.rb ├── lib └── socket2.rb ├── socket2.gemspec └── test └── internal.rb /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | *.gem 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Prolaag 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | socket2 2 | ======= 3 | 4 | Addition to the native Socket class that allows layer-2 raw packet manipulation in Ruby (for Linux) 5 | 6 | Ruby only natively supports raw socket access at the network (IP) layer. This is fine if you want the system to perform services on your behalf such as address resolution, but those who want complete control have only been left with options that take us out of pure Ruby space. 7 | 8 | This single-file, pure-Ruby class provides an alternative: Access to raw sockets at the data-link (Ethernet) layer without an intermediary like libpcap. 9 | 10 | ### Dependencies 11 | 12 | None. 13 | 14 | ### Platform 15 | 16 | * Linux 17 | * Ruby 1.9 18 | 19 | ### License 20 | 21 | MIT 22 | 23 | ### Example 24 | 25 | ```ruby 26 | require_relative 'socket2.rb' 27 | 28 | # Create a layer-2 socket in a mostly familiar way 29 | sock = Socket.new(Socket::AF_PACKET, Socket::SOCK_RAW, Socket::ETH_P_ALL) 30 | 31 | # Bind that socket to an interface 32 | sock.bind_if(ARGV.first || 'eth0') 33 | 34 | # Receive a packet starting at the beginning of its Ethernet header 35 | payload, peer_addr = sock.recvfrom(1514) 36 | 37 | # Send out that same raw packet 38 | sock.send(payload, 0) 39 | ``` 40 | 41 | ### How does it work? 42 | 43 | This file opens the Socket class and adds three constants and one method to support layer-2 raw sockets. The ```bind_if()``` method manually crafts the arguments and structures normally found in linux/if_ether.h, bits/ioctls.h, and linux/sockios.h - in particular the sockaddr_ll (link layer) address structure and ifreq interface indexing structure. Ruby 1.9 doesn't define these structures for you, but we assemble the raw memory representations of those structures ourselves and Ruby passes them along through the necessary ioctl() calls. 44 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | task :test do 4 | sh 'ruby ./test/internal.rb' 5 | end 6 | 7 | task :clean do 8 | rm_rf 'pkg' 9 | rm_r 'socket2-0.1.0.gem' 10 | end 11 | 12 | task :build do 13 | sh 'gem build socket2.gemspec' 14 | end 15 | -------------------------------------------------------------------------------- /examples/arp_all.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative '../vlink.rb' 4 | 5 | # Be an ill-behaved network citizen and claim ownership of all IP addresses 6 | # using ARP. 7 | link = VLink.new(ARGV.first || 'eth0') 8 | loop do 9 | pkt = link.parse(link.recv) # grab and parse each packet 10 | 11 | # For every ARP request... 12 | if pkt[:protocol] == :arp and pkt[:operation] == :request 13 | reply = link.reverse(pkt) # base our reply off the request 14 | reply[:sender_mac] = link.src_mac # claim that IP belongs to our MAC 15 | reply[:operation] = :reply # indicate to ARP that this is a reply 16 | link.unparse(reply) # send the packet 17 | end 18 | end 19 | 20 | -------------------------------------------------------------------------------- /examples/udp_dump.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative '../vlink.rb' 4 | 5 | # Fetch all broadcast UDP traffic and dump the payloads to stdout 6 | link = VLink.new(ARGV.first || 'eth0') 7 | broadcast = link.ip_addr(0xFFFFFFFF) 8 | 9 | loop do 10 | pkt = link.parse(link.recv) # grab and parse each packet 11 | 12 | # For every UDP packet bound for the broadcast address... 13 | if pkt[:protocol] == :udp and pkt[:dst_ip] == broadcast 14 | puts pkt[:payload].inspect 15 | end 16 | end 17 | 18 | -------------------------------------------------------------------------------- /extra/vlink.rb: -------------------------------------------------------------------------------- 1 | # encoding: ASCII-8BIT 2 | 3 | require 'socket' 4 | 5 | # This class provides raw, layer-2 packet access - no more, no less. 6 | class VLink 7 | ETH_P_ALL = 0x0003 # linux/if_ether.h 8 | SIOCGIFINDEX = 0x8933 # bits/ioctls.h 9 | SIOCGIFHWADDR = 0x8927 # linux/sockios.h 10 | 11 | def initialize(interface) 12 | @eth_p_all_hbo = [ ETH_P_ALL ].pack('S>').unpack('S').first 13 | @raw = Socket.open(Socket::AF_PACKET, Socket::SOCK_RAW, @eth_p_all_hbo) 14 | 15 | # Use an ioctl to get the MAC address of the provided interface 16 | ifreq = [ interface, '' ].pack('a16a16') 17 | @raw.ioctl(SIOCGIFHWADDR, ifreq) 18 | @src_mac = ifreq[18, 6] 19 | 20 | # Also get the system's internal interface index value 21 | ifreq = [ interface, '' ].pack('a16a16') 22 | @raw.ioctl(SIOCGIFINDEX, ifreq) 23 | index_str = ifreq[16, 4] 24 | 25 | # Build our sockaddr_ll struct so we can bind to this interface. The struct 26 | # is defined in linux/if_packet.h and requires the interface index. 27 | @sll = [ Socket::AF_PACKET, ETH_P_ALL, index_str ].pack('SS>a16') 28 | @raw.bind(@sll) 29 | end 30 | attr_reader :raw, :src_mac 31 | 32 | # Send raw data out of our socket. Provide an ethernet frame 33 | # starting at the 6-byte destination MAC address. 34 | def inject(frame) 35 | @raw.send(frame, Socket::SOCK_RAW, @sll) 36 | end 37 | 38 | # Receive and return one raw frame. 39 | def recv(maxlen = 2048) 40 | @raw.recvfrom(maxlen).first 41 | end 42 | 43 | end 44 | -------------------------------------------------------------------------------- /extra/vlink_parse.rb: -------------------------------------------------------------------------------- 1 | # encoding: ASCII-8BIT 2 | 3 | require_relative 'vlink' 4 | require 'ipaddr' 5 | 6 | # This class provides raw, layer-2 packet access to a single device. 7 | # Arbitrary ethernet payloads can be injected and received, though some 8 | # convenience functions exist on top: 9 | # Sending: 10 | # =============== 11 | # frame() - send a properly constructed ethernet frame with given payload 12 | # packet() - send a properly formatted IPv4/6 packet with a given payload 13 | # segment() - send a TCP segment with the given payload 14 | # datagram() - send a UDP datagram with the given payload 15 | # control_message() - send an ICMP packet 16 | # arp() - send an ARP request or reply 17 | # unparse() - send a packet and option hash exactly as produced by parse() 18 | # 19 | # Receiving: 20 | # =============== 21 | # parse() - parses a raw packet received through recv() and returns an 22 | # appropriate "opts" hash and the highest payload level parseable. 23 | # packet types are :ethernet, :ipv4, :ipv6, :tcp, :udp, :arp. 24 | # 25 | # opts Hash: 26 | # =============== 27 | # :src/dst_mac - six byte NBO mac address 28 | # :src/dst_ip - IPv4 or IPv6 address 29 | # :src/dst_port - integer 1-65535 representing TCP/UDP port 30 | # :norecurse - flag indicating non-recursive construction 31 | # :noinject - flag that no injection should be performed, only construction 32 | # :flags - array of TCP flags to be set on segments, :syn, :ack, :fin, :rst 33 | # :frag - hash of fragmentation options, :df, :mf, and :offset 34 | # :seq - sequence number (TCP only) 35 | # :ack - acknowledgement number (TCP only) 36 | # :window - flow control window to advertise (TCP only, defaults to 0x8000) 37 | # :sender/target_mac - six byte NBO mac address for ARP packets 38 | # :sender/target_ip - IPv4 (not IPv6) address for ARP packets 39 | class VLink 40 | ETH_P_ALL = 0x00_03 # linux/if_ether.h 41 | SIOCGIFINDEX = 0x89_33 # bits/ioctls.h 42 | SIOCGIFHWADDR = 0x89_27 # linux/sockios.h 43 | IFR_HWADDR_OFF = 18 # offset of MAC data in ifreq struct 44 | AF_INET = Socket::AF_INET # 2 45 | AF_INET6 = Socket::AF_INET6 # 10 46 | AF_PACKET = Socket::AF_PACKET # 17 47 | LCG_A = 6364136223846793005 48 | LCG_C = 1442695040888963407 49 | IP_PROTO_TCP = Socket::IPPROTO_TCP # 6 50 | IP_PROTO_UDP = Socket::IPPROTO_UDP # 17 51 | IP_PROTO_ICMP = Socket::IPPROTO_ICMP # 1 52 | ETHERTYPE_IP = 0x0800 53 | ETHERTYPE_IPV6 = 0x86dd 54 | ETHERTYPE_ARP = 0x0806 55 | 56 | # Provide the name of a physical interface (eth0), or nil if you don't 57 | # want to bind to an interface at all. 58 | def initialize(interface) 59 | @pseed = 2147483587 60 | @dst_mac = "\xff" * 6 # broadcast by default 61 | 62 | # Without an interface use a dummy source MAC 63 | unless interface 64 | @src_mac = "SRCMAC" 65 | return nil 66 | end 67 | 68 | # Open our layer-2 raw socket 69 | @eth_p_all_hbo = [ ETH_P_ALL ].pack('S>').unpack('S').first 70 | @raw = Socket.open(AF_PACKET, Socket::SOCK_RAW, @eth_p_all_hbo) 71 | 72 | # Use an ioctl to get the MAC address of the provided interface 73 | ifreq = [ interface, '' ].pack('a16a16') 74 | @raw.ioctl(SIOCGIFHWADDR, ifreq) 75 | @src_mac = ifreq[IFR_HWADDR_OFF, 6] 76 | 77 | # Also get the system's internal interface index value 78 | ifreq = [ interface, '' ].pack('a16a16') 79 | @raw.ioctl(SIOCGIFINDEX, ifreq) 80 | index_str = ifreq[16, 4] 81 | 82 | # Construct our sockaddr_ll structure. This is defined in 83 | # linux/if_packet.h, and it requires the interface index 84 | @sll = [ AF_PACKET ].pack('S') # needs to be in HBO 85 | @sll << [ ETH_P_ALL ].pack('S>') # needs to be in NBO 86 | @sll << index_str # ifr_ifindex field of ifreq structure 87 | @sll << ("\x00" * 12) 88 | 89 | # The setsockopt() call with SO_BINDTODEVICE only binds the socket to the 90 | # given interface for the purpose of sending. bind(), on the other hand, 91 | # works for both. So that's all we call. 92 | # @raw.setsockopt(Socket::SOL_SOCKET, Socket::SO_BINDTODEVICE, interface) 93 | # @raw.getsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF).inspect 94 | @raw.bind(@sll) 95 | end 96 | attr_accessor :src_mac, :dst_mac 97 | attr_reader :raw 98 | 99 | 100 | ## Core injection and reception functions ## 101 | 102 | # Send raw data out of our little socket. Provide an ethernet frame 103 | # starting at the 6-byte destination MAC address. 104 | def inject(frame) 105 | @raw.send(frame, Socket::SOCK_RAW, @sll) if @raw 106 | end 107 | 108 | # Receive and return one raw frame. 109 | def recv 110 | @raw.recvfrom(4000).first if @raw 111 | end 112 | 113 | 114 | ## Packet injection ## 115 | 116 | # Construct an ethernet frame with the given payload and ethertype 117 | def frame(ethertype, payload, opts = {}) 118 | src_mac = opts[:src_mac] || @src_mac 119 | dst_mac = opts[:dst_mac] || @dst_mac 120 | ethertype = [ ethertype ].pack('S>') if ethertype.class <= Integer 121 | frm = dst_mac + src_mac + ethertype + payload 122 | inject(frm) unless opts[:noinject] or opts[:norecurse] 123 | frm 124 | end 125 | 126 | # Construct an ARP packet that's either a :request or a :reply. Only IPv4 127 | # over ethernet is supported 128 | def arp(operation, opts = {}) 129 | sender_mac = opts[:sender_mac] || opts[:src_mac] || @src_mac 130 | target_mac = opts[:target_mac] || "\x00\x00\x00\x00\x00\x00" 131 | sender_ip = ip_addr(opts[:sender_ip]) 132 | target_ip = ip_addr(opts[:target_ip]) 133 | unless sender_ip.length == 4 and target_ip.length == 4 134 | raise "ARP: not an IPv4 address" 135 | end 136 | 137 | # Construct our packet. All types have the same form 138 | pkt = "\x00\x01\x08\x00\06\x04" # hardware/protocol type and sizes 139 | pkt << { :request => "\x00\x01", :reply => "\x00\x02" }[operation] 140 | pkt << sender_mac 141 | pkt << sender_ip 142 | pkt << target_mac 143 | pkt << target_ip 144 | return frame(ETHERTYPE_ARP, pkt, opts) unless opts[:norecurse] 145 | pkt 146 | end 147 | 148 | # Construct an IP packet with the given payload and next protocol 149 | def packet(protocol, payload, opts = {}) 150 | src_ip = ip_addr(opts[:src_ip]) 151 | dst_ip = ip_addr(opts[:dst_ip]) 152 | pkt = '' 153 | ipsum_offset = tcpsum_offset = nil 154 | ipo = opts[:ip_options] || '' 155 | raise "IP options must be a multiple of 4 bytes" if ipo.length % 4 != 0 156 | raise "IP options too long" if ipo.length > 40 157 | 158 | # IPv4 header 159 | if src_ip.length == 4 and dst_ip.length == 4 160 | frag = 0 161 | 162 | # Calculate our fragmentation fields 163 | if opts[:frag] 164 | frag |= 0x4000 if opts[:frag][:df] 165 | frag |= 0x2000 if opts[:frag][:mf] 166 | offset = opts[:frag][:offset].to_i 167 | if (offset & 7) != 0 or offset > 65528 or offset < 0 168 | raise "invalid IP fragment offset" 169 | end 170 | frag += (offset / 8) 171 | end 172 | 173 | # Pre-pack our numeric fields. Total length, in this case, hard codes 174 | # a fixed header length, which allows for no IP options. 175 | total_len = [ payload.length + 20 + ipo.length ].pack('S>') 176 | hdr = (0x45 + (ipo.length / 4)).chr 177 | frag = [ frag ].pack('S>') 178 | 179 | # Construct our packet 180 | pkt << "#{hdr}\x00#{total_len}" # IP version, ToS, length 181 | pkt << "#{prand(2)}#{frag}\x40" # ID, fragmentation, TTL 182 | pkt << protocol.chr # transport protocol 183 | ipsum_offset = pkt.length # IP checksum offset for later 184 | pkt << "\x00\x00" # checksum placeholder 185 | pkt << src_ip 186 | pkt << dst_ip 187 | pkt << ipo 188 | tcpsum_offset = pkt.length + 16 # TCP checksum offset for later 189 | pkt << payload.to_s 190 | 191 | # IPv6 header 192 | elsif src_ip.length == 16 and dst_ip.length == 16 193 | # ADD CODE HERE 194 | raise "IPv6 not yet supported" 195 | else 196 | raise "IP version mismatch" 197 | end 198 | 199 | # Now if we're using TCP, we need to calculate its checksum 200 | if protocol == IP_PROTO_TCP 201 | checksum = 6 + payload.length 202 | pos = ipsum_offset + 2 203 | while pos < pkt.length 204 | checksum += (pkt[pos].ord << 8) + (pkt[pos+1] || 0).ord 205 | checksum = (checksum + 1) & 0xFFFF if checksum > 0xFFFF 206 | pos += 2 207 | end 208 | checksum = checksum ^ 0xFFFF 209 | pkt[tcpsum_offset, 2] = [ checksum ].pack('S>') 210 | end 211 | 212 | # Finally, calculate the IP checksum (unless IPv6, which has no checksum) 213 | if src_ip.length == 4 214 | pos = checksum = 0 215 | while pos < 20 + ipo.length 216 | checksum += (pkt[pos].ord << 8) + pkt[pos + 1].ord; 217 | checksum = (checksum + 1) & 0xFFFF if checksum > 0xFFFF 218 | pos += 2 219 | end 220 | checksum = checksum ^ 0xFFFF 221 | pkt[ipsum_offset, 2] = [ checksum ].pack('S>') 222 | end 223 | 224 | # If we're injecting, recurse down 225 | return frame(ETHERTYPE_IP, pkt, opts) unless opts[:norecurse] 226 | pkt 227 | end 228 | 229 | # Create a TCP segment with the provided payload 230 | def segment(payload, opts = {}) 231 | src_port = opts[:src_port].to_i 232 | dst_port = opts[:dst_port].to_i 233 | seq, ack = opts[:seq].to_i, opts[:ack].to_i 234 | fw = opts.fetch(:window, 0x8000) 235 | 236 | # Validate our flags 237 | flags = opts.fetch(:flags, [:ack]) 238 | unless flags.include?(:ack) or flags.include?(:syn) 239 | raise "TCP segments must have either SYN or ACK set" 240 | end 241 | ack = 0 unless flags.include?(:ack) 242 | 243 | # Construct the segment, starting with src and dst ports 244 | seg = [src_port].pack('S>') + [dst_port].pack('S>') 245 | seg << [seq].pack('L>') + [ack].pack('L>') # sequence numbers 246 | flag_bits = 0 247 | flag_bits |= 0x01 if flags.include?(:fin) 248 | flag_bits |= 0x02 if flags.include?(:syn) 249 | flag_bits |= 0x04 if flags.include?(:rst) 250 | flag_bits |= 0x08 if flags.include?(:psh) 251 | flag_bits |= 0x10 if flags.include?(:ack) 252 | seg << "\x50#{flag_bits.chr}#{[fw].pack('S>')}" # hdr_len, flags, window 253 | seg << "\x00\x00\x00\x00" # checksum, URG pointer 254 | seg << payload.to_s 255 | return packet(IP_PROTO_TCP, seg, opts) unless opts[:norecurse] 256 | seg 257 | end 258 | 259 | # Create a UDP datagram with the provided payload 260 | def datagram(payload, opts = {}) 261 | src_port = (opts[:src_port]).to_i 262 | dst_port = (opts[:dst_port]).to_i 263 | dgram = [src_port].pack('S>') + [dst_port].pack('S>') 264 | dgram << [payload.length + 8].pack('S>') # total length 265 | dgram << "\x00\x00" # zero out the checksum 266 | dgram << payload.to_s 267 | return packet(IP_PROTO_UDP, dgram, opts) unless opts[:norecurse] 268 | dgram 269 | end 270 | 271 | # Create an ICMP control message with the provided payload. The checksum 272 | # will be inserted over the 3rd and 4th bytes of the provided payload. 273 | def control_message(payload, opts = {}) 274 | msg = payload.dup 275 | msg[2, 2] = "\0\0" 276 | 277 | # Calculate the checksum 278 | checksum = pos = 0 279 | while pos < msg.length do 280 | checksum += (msg[pos].ord << 8) + (msg[pos+1] || 0).ord 281 | checksum = (checksum + 1) & 0xFFFF if checksum > 0xFFFF 282 | pos += 2 283 | end 284 | checksum = checksum ^ 0xFFFF 285 | msg[2, 2] = [ checksum ].pack('S>') 286 | return packet(IP_PROTO_ICMP, msg, opts) unless opts[:norecurse] 287 | msg 288 | end 289 | 290 | 291 | ## Frame parsing and unparsing ## 292 | 293 | # Parse a received frame up to the highest level understood by VLink. If 294 | # a parsing error was encountered, it will be made available in :error. 295 | def parse(frame) 296 | opts = { :frame => frame, 297 | :dst_mac => frame[0, 6], 298 | :src_mac => frame[6, 6], 299 | :ethertype => frame[12, 2].unpack('S>').first } 300 | 301 | # Parse out our different layer 3 protocols 302 | parser = { ETHERTYPE_ARP => :parse_arp, 303 | ETHERTYPE_IP => :parse_ipv4, 304 | ETHERTYPE_IPV6 => :parse_ipv6 }[opts[:ethertype]] 305 | begin 306 | return method(parser).call(frame[14..-1], opts) if parser 307 | rescue 308 | opts[:error] = $!.to_s 309 | end 310 | 311 | # If we got this far, all we know is that it's an ethernet frame 312 | opts[:protocol] = :ethernet 313 | opts[:payload] = frame[14..-1] 314 | return opts 315 | end 316 | 317 | def parse_arp(data, opts) 318 | if data.length < 28 or data[0, 6] != "\x00\x01\x08\x00\x06\x04" 319 | raise "ARP: truncated packet or not ethernet-IP" 320 | end 321 | op = { "\x00\x01" => :request, "\x00\x02" => :reply }[data[6, 2]] 322 | raise "ARP: invalid operation" unless op 323 | opts[:operation] = op 324 | opts[:sender_mac] = data[8, 6] 325 | opts[:sender_ip] = data[14, 4] 326 | opts[:target_mac] = data[18, 6] 327 | opts[:target_ip] = data[24, 4] 328 | opts[:protocol] = :arp 329 | return opts 330 | end 331 | 332 | def parse_ipv4(data, opts) 333 | raise "IPv4: truncated packet" unless data.length > 20 334 | 335 | # Parse and validate the header 336 | hdr_len = (data[0].ord - 0x40) * 4 337 | raise "IPv4: invalid hdr_len" if hdr_len > data.length or hdr_len < 20 338 | payload_len = data[2, 2].unpack('S>').first - hdr_len 339 | raise "IPv4: truncated payload" if payload_len + hdr_len > data.length 340 | raise "IPv4: negative payload length" if payload_len < 0 341 | payload = data[hdr_len, payload_len] 342 | 343 | # Gather and store fragmentation data 344 | frag_bits = data[6, 2].unpack('S>').first 345 | if frag_bits != 0 346 | opts[:frag] = frag = { :offset => frag_bits & 0x1FFF } 347 | frag[:mf] = true if (frag_bits & 0x2000) != 0 348 | frag[:df] = true if (frag_bits & 0x4000) != 0 349 | end 350 | 351 | # It all looks good, construct our packet description 352 | opts[:transport] = data[9].ord 353 | opts[:src_ip] = data[12, 4] 354 | opts[:dst_ip] = data[16, 4] 355 | 356 | # Don't parse any higher-layer protocols if this is a fragment 357 | opts[:transport] = 0 if (frag_bits & 0x3FFF) > 0 358 | 359 | # Recurse into the payload if we understand the transport protocol 360 | parser = { IP_PROTO_TCP => :parse_tcp, 361 | IP_PROTO_UDP => :parse_udp }[opts[:transport]] 362 | begin 363 | return method(parser).call(payload, opts) if parser 364 | rescue 365 | opts[:error] = $!.to_s 366 | end 367 | 368 | # If we got this far, all we know is that it's an IP packet 369 | opts[:protocol] = :ipv4 370 | opts[:protocol] = :icmp if opts[:transport] == IP_PROTO_ICMP 371 | opts[:payload] = payload 372 | return opts 373 | end 374 | 375 | def parse_ipv6(data, opts) 376 | raise "IPv6: not yet supported" 377 | end 378 | 379 | def parse_tcp(data, opts) 380 | raise "TCP: truncated segment" if data.length < 20 381 | 382 | # Validate the header. If reserved bits are set, meh 383 | hdr_len = data[12].ord >> 2 384 | raise "TCP: invalid hdr_len" if data.length < hdr_len or hdr_len < 20 385 | 386 | # Okay, this looks good, construct our segment description 387 | hdr_vals = data.unpack('S>S>L>L>') 388 | opts[:src_port], opts[:dst_port], opts[:seq], opts[:ack] = hdr_vals 389 | flag_bits = data[13].ord 390 | flags = opts[:flags] = [] 391 | flags << :fin if (flag_bits & 0x01) != 0 392 | flags << :syn if (flag_bits & 0x02) != 0 393 | flags << :rst if (flag_bits & 0x04) != 0 394 | flags << :psh if (flag_bits & 0x08) != 0 395 | flags << :ack if (flag_bits & 0x10) != 0 396 | opts[:payload] = data[hdr_len..-1] 397 | opts[:protocol] = :tcp 398 | return opts 399 | end 400 | 401 | def parse_udp(data, opts) 402 | raise "UDP: truncated datagram" if data.length < 8 403 | src, dst, tot_len = data.unpack('S>S>S>') 404 | raise "UDP: truncated payload" if tot_len < data.length 405 | 406 | # Alright, this packet looks good. Pass it along. 407 | opts[:src_port], opts[:dst_port] = src, dst 408 | opts[:payload] = data[8, tot_len - 8] 409 | opts[:protocol] = :udp 410 | return opts 411 | end 412 | 413 | # Take a parsed packet and return a src <-> dst reversed copy. 414 | PACKET_REVERSAL = { 415 | :src_mac => :dst_mac, :dst_mac => :src_mac, 416 | :src_ip => :dst_ip, :dst_ip => :src_ip, 417 | :src_port => :dst_port, :dst_port => :src_port, 418 | :sender_mac => :target_mac, :target_mac => :sender_mac, 419 | :sender_ip => :target_ip, :target_ip => :sender_ip, 420 | } 421 | def reverse(opts) 422 | rev = {} 423 | opts.each { |k,v| rev[PACKET_REVERSAL[k] || k] = v } 424 | rev.delete :src_mac 425 | rev 426 | end 427 | 428 | # This nifty method takes an opts hash returned by parse() and calls the 429 | # appropriate injector method with the appropriate options set. 430 | def unparse(opts) 431 | case opts[:protocol] 432 | when :ethernet 433 | frame(opts[:ethertype], opts[:payload], opts) 434 | when :arp 435 | arp(opts[:operation], opts) 436 | when :ipv4 437 | packet(opts[:transport], opts[:payload], opts) 438 | when :ipv6 439 | packet(opts[:transport], opts[:payload], opts) 440 | when :tcp 441 | segment(opts[:payload], opts) 442 | when :udp 443 | datagram(opts[:payload], opts) 444 | when :icmp 445 | control_message(opts[:payload], opts) 446 | else 447 | raise "Unknown protocol: #{opts[:protocol]}" 448 | end 449 | end 450 | 451 | 452 | ## Helper functions ## 453 | 454 | # Helper routine to generate pseudorandom integers. Provide a width to get an 455 | # array of random bytes, otherwise a random 64-bit integer will be returned. 456 | def prand(width = nil) 457 | @pseed = (@pseed * LCG_A + LCG_C) % 2**64 458 | if width 459 | return [ @pseed ].pack("Q") + prand(width - 8) if width > 8 460 | return [ @pseed ].pack("Q")[0, width] 461 | end 462 | @pseed 463 | end 464 | 465 | # Helper routine which takes an integer, IPAddr object, dotted-quad string, 466 | # or 4/16 character NBO string and homogenizes it into the latter form. 467 | def ip_addr(val) 468 | case val 469 | when Integer 470 | return [val].pack('L>') if val <= 0xFFFFFFFF 471 | return IPAddr.new(val, AF_INET6).hton 472 | when IPAddr 473 | return val.hton 474 | when String 475 | return val if val.length == 4 || val.length == 16 476 | return IPAddr.new(val).hton rescue nil 477 | end 478 | raise "invalid IP address: #{val.inspect}" 479 | end 480 | 481 | end 482 | -------------------------------------------------------------------------------- /extra/vtap.rb: -------------------------------------------------------------------------------- 1 | # encoding: ASCII-8BIT 2 | 3 | require 'socket' 4 | 5 | # This class creates a non-persistent TAP device on your system and provides 6 | # raw, layer-2 packet access to it. 7 | class VTap 8 | ETH_P_ALL = 0x0003 # linux/if_ether.h 9 | SIOCGIFHWADDR = 0x8927 # linux/sockios.h 10 | IFF_TAP = 0x0002 # linux/if_tun.h 11 | TUNSETIFF = 0x400454ca # _IOW('T', 202, int) 12 | SIOCGIFFLAGS = 0x8913 # from linux/sockios.h 13 | SIOCSIFFLAGS = 0x8914 # from linux/sockios.h 14 | IFF_UP = 0x0001 # from net/if.h 15 | IFF_RUNNING = 0x0040 # from net/if.h 16 | 17 | def initialize(tap = 'tinytap') 18 | @tap = tap 19 | @eth_p_all_hbo = [ ETH_P_ALL ].pack('S>').unpack('S').first 20 | 21 | # First let's define our ifreq structure. It's 32 bytes - the first 16 22 | # hold the tap name, and the second 16 hold (in our case) the flags. 23 | ifreq = [ tap, IFF_TAP, '' ].pack('a16S').unpack('S').first 14 | 15 | # Bind a layer-2 raw socket to the given interface 16 | def bind_if(interface) 17 | # Get the system's internal interface index value 18 | ifreq = [ interface, '' ].pack('a16a16') 19 | self.ioctl(SIOCGIFINDEX, ifreq) 20 | index_str = ifreq[16, 4] 21 | 22 | # Build our sockaddr_ll struct so we can bind to this interface. The struct 23 | # is defined in linux/if_packet.h and requires the interface index. 24 | eth_p_all_hbo = [ ETH_P_ALL ].pack('S').unpack('S>').first 25 | sll = [ Socket::AF_PACKET, eth_p_all_hbo, index_str ].pack('SS>a16') 26 | self.bind(sll) 27 | end 28 | 29 | end 30 | 31 | # Example 32 | # ------- 33 | # 34 | # 1) Create a raw, layer-2 socket: 35 | # sock = Socket.new(Socket::AF_PACKET, Socket::SOCK_RAW, Socket::ETH_P_ALL) 36 | # 37 | # 2) Bind to a network interface: 38 | # sock.bind_if('eth0') 39 | # 40 | # 3) Receive a packet starting at the beginning of its Ethernet header: 41 | # payload, peer_addr = sock.recvfrom(1514) 42 | # 43 | # 4) Send that same raw packet: 44 | # sock.send(payload, 0) 45 | # 46 | # 5) Fetch the MAC address of the bound interface: 47 | # sock.local_address.to_sockaddr[-6, 6] 48 | # 49 | # 50 | # License 51 | # ------- 52 | # MIT 53 | -------------------------------------------------------------------------------- /socket2.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'socket2' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "socket2" 8 | spec.version = Socket2::VERSION 9 | spec.authors = ["Prolaag"] 10 | spec.email = ["prolaag@gmail.com"] 11 | 12 | spec.summary = "Pure Ruby addition to Socket that allows layer-2 raw access in Linux" 13 | spec.description = <<-EOT 14 | This pure Ruby addition to the Socket class provides a means of creating 15 | and manipulating layer-2 raw sockets. By default Ruby only provides native 16 | access to raw sockets at layer-3 (IP). This addition only supports 17 | Linux platforms. 18 | EOT 19 | spec.homepage = "https://github.com/prolaag/socket2" 20 | spec.license = "MIT" 21 | 22 | spec.files = Dir.glob("lib/**/*") + 23 | %w(README.md Gemfile LICENSE.txt socket2.gemspec) 24 | spec.require_paths = ["lib"] 25 | 26 | spec.add_development_dependency "bundler", "~> 1.9" 27 | spec.add_development_dependency "rake", "~> 10.0" 28 | end 29 | -------------------------------------------------------------------------------- /test/internal.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # encoding: ASCII-8BIT 3 | 4 | # This test constructs a tap interface that responds to all ping packets, 5 | # then opens a layer-2 raw socket on that interface to inject pings and receive 6 | # responses. 7 | 8 | require 'ipaddr' 9 | require 'test/unit' 10 | require_relative '../lib/socket2' 11 | require_relative '../extra/vtap.rb' 12 | 13 | class TapTestHelper 14 | 15 | def initialize 16 | @tap_name = "ttap#{rand(9000) + 1000}" 17 | @tap = VTap.new(@tap_name) 18 | @ping_thread = nil 19 | @sock = Socket.new(Socket::AF_PACKET, Socket::SOCK_RAW, Socket::ETH_P_ALL) 20 | @sock.bind_if @tap_name 21 | end 22 | attr_reader :tap_name, :tap, :sock, :ping_thread 23 | 24 | # This method spawns a thread 25 | def answer_pings 26 | @ping_thread = Thread.new do 27 | begin 28 | loop do 29 | pkt = @tap.recv 30 | icmp = icmp_offset(pkt) 31 | if icmp and pkt[icmp] == "\x08" # type == Echo Request 32 | pkt[icmp, 1] = "\x00" # type == Echo Reply 33 | pkt[26, 4], pkt[30, 4] = pkt[30, 4], pkt[26, 4] # reverse IPs 34 | @tap.inject(pkt) 35 | end 36 | end 37 | rescue Object 38 | $stderr.puts $! 39 | $stderr.puts $@ 40 | Kernel.exit(1) 41 | end 42 | end 43 | end 44 | 45 | # If this is an IPv4 ICMP packet, return its payload offset, otherwise 46 | # return false / nil 47 | def icmp_offset(pkt) 48 | return false unless pkt[12, 2] == "\x08\x00" and # ethertype = IPv4 49 | pkt[23, 1] == "\x01" # IPProto = ICMP 50 | offset = 14 + ([ pkt[14].ord & 0x0F, 5 ].max * 4) 51 | end 52 | 53 | # Return the MAC address of the tap device 54 | def tap_mac 55 | @sock.local_address.to_sockaddr[-6, 6] 56 | end 57 | 58 | # Wait for and return the next ping reply on the raw socket up to timeout 59 | def ping_reply(timeout = 1.0) 60 | loop do 61 | st = Time.now.to_f 62 | act = select([@sock], [], [@sock], timeout) 63 | return nil if !act or act.first.empty? 64 | pkt = @sock.recv(1514) 65 | icmp = icmp_offset(pkt) 66 | return pkt if icmp and pkt[icmp] == "\x00" # type = Echo Reply 67 | timeout = timeout - Time.now.to_f + st 68 | return nil if timeout <= 0 69 | end 70 | end 71 | 72 | # Send the given raw layer-2 packet to the tap 73 | def inject(frame) 74 | @sock.send(frame, 0) 75 | end 76 | 77 | end 78 | 79 | class TestSocket2 < Test::Unit::TestCase 80 | 81 | def setup 82 | begin 83 | @tt = TapTestHelper.new 84 | rescue Errno::EPERM 85 | $stderr.puts "You must be root to create raw sockets" 86 | Kernel.exit(1) 87 | end 88 | 89 | # Tell the test tap to respond to ping packets 90 | @tt.answer_pings 91 | 92 | # Define a basic ping packet manually 93 | @ping = [ 94 | # Ethernet header 95 | @tt.tap_mac, # dst MAC 96 | @tt.tap_mac, # source MAC 97 | [ 0x0800 ].pack('S>'), # IPv4 ethertype 98 | 99 | # IP header 100 | [ 0x45, 0, 20 + 8 ].pack('CCS>'), # version, IHL, header + total len 101 | [ rand(2**16), 0 ].pack('S>S>'), # packet ID, fragmentation 102 | [ 64, 1, rand(2**16) ].pack('CCS>'), # TTL, protocol, garbage checksum 103 | IPAddr.new('1.2.3.4').hton, # src IP 104 | IPAddr.new('9.8.7.6').hton, # dst IP 105 | 106 | # ICMP 107 | [ 8, 0, 0, 0 ].pack('CCS>L>'), # type, code, checksum, RoH 108 | ].join 109 | end 110 | 111 | # Inject the ping, wait for a reply 112 | def test_ping_basic 113 | @tt.sock.send(@ping, 0) 114 | pong = @tt.ping_reply 115 | assert_equal(42, pong.length) 116 | assert_equal(pong[0, 6], @tt.tap_mac) 117 | assert_equal(pong[30, 4], @ping[26, 4]) 118 | assert_equal(pong[26, 4], @ping[30, 4]) 119 | end 120 | 121 | end 122 | --------------------------------------------------------------------------------