Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add DHCP protocol handling for packetbeat #7359

Closed
wants to merge 10 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions packetbeat/protos/dhcp/_meta/fields.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
- key: dhcp
title: "DHCP"
description: DHCP-specific event fields
fields:
- name: dhcp
type: group
fields:
- name: transaction_id
type: string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

string is not a valid type in Elasticsearch, this must be keyword.

description: Generated by the client, allows the client to correctly match requests and responses.

- name: client_ip
type: string
description: The current IP address of the client.

- name: assigned_ip
type: string
description: The IP address that the DHCP server is assigning to the client.

- name: server_ip
type: string
description: The IP address of the DHCP server that the client should use for the next step in the bootstrap process.

- name: gateway_ip
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a better name for this would be relay_ip.

type: string
description: The gateway IP address used by the client to contact the server.

- name: client_hwaddr
type: string
description: The client's hardware (layer two) address.

- name: server_name
type: string
description: Optional, for DHCPOFFER or DHCPACK messages, the name of the server sending the message.

- name: op_code
type: int
description: The general type of DHCP message.

- name: hops
type: int
description: The number of hops the DHCP message went through.

- name: hardware_type
type: string
description: The type of hardware used for the local network (Ethernet, LocalTalk, etc)

- name: message_type
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think message_type, server_identifier, and subnet_mask should be prefixed with option. to highlight these are options in the DHCP message.

type: string
description: The specific type of DHCP message being sent (DHCPOFFER, DCHPREQUEST, etc)

- name: server_identifier
type: string
description: IP address of the individual DHCP server which handled this message.

- name: subnet_mask
type: string
description: The subnet mask that the client should use on the currnet network.

13 changes: 13 additions & 0 deletions packetbeat/protos/dhcp/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package dhcp

import (
"github.com/elastic/beats/packetbeat/config"
)

type dhcpConfig struct {
config.ProtocolCommon `config:",inline"`
}

var (
defaultConfig = dhcpConfig{}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be initialized with the default ports.

	defaultConfig = dhcpConfig{
		ProtocolCommon: config.ProtocolCommon{
			Ports: []int{67, 68},
		},
	}

)
195 changes: 195 additions & 0 deletions packetbeat/protos/dhcp/dhcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package dhcp

import (
"bytes"
"encoding/hex"
"github.com/elastic/beats/libbeat/beat"
"github.com/elastic/beats/libbeat/common"
"github.com/elastic/beats/libbeat/logp"
"github.com/elastic/beats/packetbeat/protos"
"net"
)

var hardwareTypes = map[int]string{
1: "ethernet",
6: "ieee_802",
7: "arcnet",
11: "localtalk",
12: "localnet",
14: "smds",
15: "frame_relay",
16: "atm",
17: "hdlc",
18: "fibre_channel",
19: "atm",
20: "serial_line",
}

var messageTypes = []string{
"DHCPDISCOVER",
"DHCPOFFER",
"DHCPREQUEST",
"DHCPDECLINE",
"DHCPACK",
"DHCPNAK",
"DHCPRELEASE",
"DHCPINFORM",
}

type dhcpPlugin struct {
ports []int
results protos.Reporter
}

func (dhcp *dhcpPlugin) init(results protos.Reporter, config *dhcpConfig) {
dhcp.ports = config.Ports
dhcp.results = results
}

func (dhcp *dhcpPlugin) GetPorts() []int {
return dhcp.ports
}

func payloadToRows(payload []byte) [][]byte {
result := make([][]byte, 0)
var idx = 0
for idx < len(payload) {
result = append(result, []byte{payload[idx], payload[idx+1], payload[idx+2], payload[idx+3]})
idx += 4
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this function much. It's unnecessary allocation that is happening for every DHCP packet.
All the uses of this function's result can be replaced by slicing pkt.Payload:
rows[3] => pkt.Payload[12:16]
combineRows(rows,11,16) => pkt.Payload[44:108]

Also the bounds check is wrong and will cause a panic for a payload whose size is not a multiple of 4.
for idx + 4 <= len(payload) or similar should do.

return result
}

func bytesToIPv4(bytes []byte) string {
ip := net.IPv4(bytes[0], bytes[1], bytes[2], bytes[3])
str := ip.String()
if str == "0.0.0.0" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of comparing with "0.0.0.0" string, use ip.IsUnspecified()

return ""
}
return str
}

func bytesToHardwareAddress(bytes []byte, length int) string {
return net.HardwareAddr(bytes[0:length]).String()
}

func combineRows(rows [][]byte, start int, length int) []byte {
combined := make([]byte, 0)
end := start + length
var idx = start
for idx < end {
combined = append(combined, rows[idx]...)
idx++
}
return combined
}

func trimNullBytes(b []byte) []byte {
index := bytes.Index(b, []byte{0})
return b[0:index]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will panic if for some reason the byte 0 is not found within the slice. You need to validate the result of bytes.Index.

Nit: use bytes.IndexByte

}

func extractOptions(payload []byte) []byte {
result := make([]byte, 0)
var idx = 240
for idx < len(payload) {
result = append(result, payload[idx])
idx++
}
return result
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as just using payload[240:], don't need a function for this unless there is a point in copying the slice, which you can do with copy(...)


func parseOptions(options []byte) map[int][]byte {
result := make(map[int][]byte)
var remaining = 0
var option = 0
var body = make([]byte, 0)
var idx = 0
for idx < len(options) {
if remaining == 0 {
if option != 0 {
result[option] = body
body = make([]byte, 0)
}
option = int(options[idx])
if option == 0 || option == 255 {
idx = len(options)
} else {
idx++
remaining = int(options[idx])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing bounds check here, it can panic on a malformed or crafted packet.

}
} else {
body = append(body, options[idx])
remaining--
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function can be improved to read the option at once instead of looping and appending every byte:

remaining = int(options[idx])
if idx + remaining + 1 > len(options) {
   error...
}
result[option] = options[idx+1:idx+1+remaining]

As before, I'm not sure if it's necessary to copy into a new slice or not.

}
idx++
}
return result
}

func (dhcp *dhcpPlugin) parsePacket(pkt *protos.Packet) beat.Event {
dhcpFields := make(map[string]interface{})
rows := payloadToRows(pkt.Payload)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function needs bounds checking, make sure that Payload has the expected length.

dhcpFields["transaction_id"] = hex.EncodeToString(rows[1])
dhcpFields["client_ip"] = bytesToIPv4(rows[3])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several of these IPs may be unspecified. We should omit them if they are unset in the message.

dhcpFields["assigned_ip"] = bytesToIPv4(rows[4])
dhcpFields["server_ip"] = bytesToIPv4(rows[5])
dhcpFields["gateway_ip"] = bytesToIPv4(rows[6])
hwaddr := combineRows(rows, 7, 4)
dhcpFields["client_hwaddr"] = bytesToHardwareAddress(hwaddr, int(pkt.Payload[2]))
serverName := trimNullBytes(combineRows(rows, 11, 16))
dhcpFields["server_name"] = string(serverName)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this "server_name" actually used in DHCP or is it a BOOTP leftover? I couldn't find much information about it and in my captures is always empty. I suggest you don't set the field at all in dhcpFields if empty.

dhcpFields["op_code"] = int(pkt.Payload[0])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to render this value as a string like BootRequest and BootReply.

dhcpFields["hops"] = int(pkt.Payload[3])
dhcpFields["hardware_type"] = hardwareTypes[int(pkt.Payload[1])]
options := extractOptions(pkt.Payload)
parsedOptions := parseOptions(options)
if parsedOptions[53] != nil {
dhcpFields["message_type"] = messageTypes[int(parsedOptions[53][0])-1]
}
if parsedOptions[54] != nil {
dhcpFields["server_identifier"] = bytesToIPv4(parsedOptions[54])
}
if parsedOptions[1] != nil {
dhcpFields["subnet_mask"] = bytesToIPv4(parsedOptions[1])
}
event := beat.Event{
Timestamp: pkt.Ts,
Fields: map[string]interface{}{
"transport": "udp",
"ip": pkt.Tuple.DstIP.String(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that the ip/port and client_ip/client_port values need to be swapped for the BOOTREPLY packets in order or accurately represent the role of the two hosts in the exchange.

"client_ip": pkt.Tuple.SrcIP.String(),
"port": pkt.Tuple.DstPort,
"client_port": pkt.Tuple.SrcPort,
"type": "dhcp",
"dhcp": dhcpFields,
},
}
return event
}

func (dhcp *dhcpPlugin) ParseUDP(pkt *protos.Packet) {
event := dhcp.parsePacket(pkt)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be some error handling here. If the data isn't actually DHCP (like there aren't enough bytes make a valid DHCP packet) then an event should not be sent.

dhcp.results(event)
}

func init() {
protos.Register("dhcp", New)
}

func New(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exported function New should have comment or be unexported

testMode bool,
results protos.Reporter,
cfg *common.Config,
) (protos.Plugin, error) {
p := &dhcpPlugin{}
config := defaultConfig
if !testMode {
if err := cfg.Unpack(&config); err != nil {
logp.Err("Error unpacking configuration: %s", err)
return nil, err
}
}
p.init(results, &config)
return p, nil
}
70 changes: 70 additions & 0 deletions packetbeat/protos/dhcp/dhcp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package dhcp

import (
"fmt"
"github.com/elastic/beats/libbeat/beat"
"github.com/elastic/beats/libbeat/common"
"github.com/elastic/beats/packetbeat/protos"
"github.com/stretchr/testify/assert"
"net"
"testing"
"time"
)

var (
payload = []byte{
0x02, 0x01, 0x06, 0x00, 0x00, 0x00, 0x3d, 0x1e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xa8, 0x00, 0x0a,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0b, 0x82, 0x01, 0xfc, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x63, 0x82, 0x53, 0x63,
0x35, 0x01, 0x05, 0x3a, 0x04, 0x00, 0x00, 0x07, 0x08, 0x3b, 0x04, 0x00, 0x00, 0x0c, 0x4e, 0x33, 0x04, 0x00, 0x00, 0x0e,
0x10, 0x36, 0x04, 0xc0, 0xa8, 0x00, 0x01, 0x01, 0x04, 0xff, 0xff, 0xff, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
}
)

var _ protos.UDPPlugin = &dhcpPlugin{}

func get(event beat.Event, fieldName string) interface{} {
value, _ := event.Fields.GetValue(fieldName)
return value
}

func TestParsePacket(t *testing.T) {
plugin := dhcpPlugin{}
ipTuple := common.NewIPPortTuple(4, net.IP{192, 168, 0, 10}, 67, net.IP{192, 168, 0, 1}, 68)
pkt := &protos.Packet{
Ts: time.Now(),
Tuple: ipTuple,
Payload: payload,
}
expectedDhcpFields := map[string]interface{}{
"client_ip": "",
"client_hwaddr": "00:0b:82:01:fc:42",
"gateway_ip": "",
"hardware_type": "ethernet",
"hops": int(0),
"message_type": "DHCPACK",
"op_code": int(2),
"server_identifier": "192.168.0.1",
"server_ip": "",
"server_name": "",
"subnet_mask": "255.255.255.0",
"transaction_id": "00003d1e",
"assigned_ip": "192.168.0.10",
}
event := plugin.parsePacket(pkt)
fmt.Printf("Gpt event: %+v", event)
assert.Equal(t, "192.168.0.1", get(event, "ip").(string), "Wrong ip")
assert.Equal(t, "192.168.0.10", get(event, "client_ip").(string), "Wrong client_ip")
dhcpFields := get(event, "dhcp").(map[string]interface{})
assert.Equal(t, expectedDhcpFields, dhcpFields)
}