Skip to main content

Road to PXE - Day 3

·1068 words·6 mins

why did this take so long? #

Well you see I started this project right at a very busy time and I may have bitten off more than I could chew. In the past few months I have gotten a promotion, did a MS Cert (AZ-900) , ran my first half marathon (not a race yet, just ran the distance) and lost a few more kg’s (down 25kg this year).

So uh… I had my hands a little full to say the least 😅

Anyway, I’ve settled into things a bit more now and I’m ready to code so let’s get this show on the road.

where was I? #

In the time since the 2nd post in this series System76 released the Pop!_OS COSMIC alpha. Being the massive f*cking nerd I am, I may have switched over to it as my daily driver.

Needless to say in the excited rush to install it I didn’t backup this project.

But why aren’t you using git? I hear you screaming.

This project was in its early stages and I figured I was gonna have to throw it all away and rewrite it again.

The day of judgment has come and now I shall rewrite. Fortunately I had these blog posts to help pick the pieces back up.

back on the grind #

After a little bit of coding I was back to the same place with getting SeaBIOS able to PXE Boot but not UEFI. By switching undionly.kpxe out for ipxe.efi in the TFTP server and my router config I was able to get UEFI machines to boot. Cool…

This is all nice but I have a fancy router (EdgeRouter). What about people that can’t screw with their routers that much?

In my research so far I have found that most people are using dnsmasq to get PXE working without directly controlling the DHCP server. Looking into it more it seems what we are looking for is proxydhcp.

After some more googling I found this awesome go project by Jacob Weinstock that did what I want proxydhcp. This repo really ended up cracking this thing open for me although it took quite a while to decipher.

I will spare you the details of how I worked out things based on that repo, wireshark, and the RFCs. Instead I will just link to the docs that helped me most.

RFC 2131 - Dynamic Host Configuration Protocol
RFC 4578 - Dynamic Host Configuration Protocol (DHCP) Options for the Preboot eXecution Environment (PXE)
RFC 2132 - DHCP Options and BOOTP Vendor Extensions
RFC 1497 - BOOTP Vendor Information Extensions

The link in RFC 4578 - pix.net - pxespec.pdf
seems to be dead so I found a mirror of it here - dimitrije.website - pxespec.pdf

With that out of the way lets get into how we solve the issue

finally solving the f*cking bootfile dilema #

First things first lets turn off the PXE settings on the edgerouter so it’s just us sending PXE options.

Now that that is done I pretty quickly worked out how to create packets and send them with dhcproto.

What do we send? Packets!
What is in them? uh..

So basically what we need to do is.
If all of this is true we send a response

Packet Opcode is Boot Request
DHCP Option 53 (Message Type) is Discover or Request
DHCP Option 55 (Parameter Request List) is set
DHCP Option 60 (Class Identifier) starts with PXEClient
DHCP Option 93 (Client System Architecture) is set
DHCP Option 94 (Client Network Interface) is set

in rust with dhcproto that looks something like this.

fn handle_packet(message: Message) -> Option<Message> {
    let options = message.opts();
    let mac_address = message.chaddr();

    let opcode = message.opcode();
    let architecture = options.get(OptionCode::ClientSystemArchitecture);
    let network_interface = options.get(OptionCode::ClientNetworkInterface);
    let vendor_class = options.get(OptionCode::ClassIdentifier);
    let message_type = options.get(OptionCode::MessageType);
    let user_class = options.get(OptionCode::UserClass);
    let requested_params = options.get(OptionCode::ParameterRequestList);

    match (
        opcode,
        message_type,
        requested_params,
        vendor_class,
        architecture,
        network_interface,
    ) {
        (
            Opcode::BootRequest,                         // is a boot request
            Some(DhcpOption::MessageType(message_type)), // option 53 is set
            Some(DhcpOption::ParameterRequestList(_)),   //option 55 is set
            Some(DhcpOption::ClassIdentifier(class_id)), // option 60 is set
            Some(DhcpOption::ClientSystemArchitecture(request_architecture)), // option 93 is set
            Some(DhcpOption::ClientNetworkInterface(_, _, _)), // option 94 is set
        ) => {
            if message_type != &MessageType::Discover && message_type != &MessageType::Request {
                // message_type(opt 53) must be Discover or Request
                return None;
            }

            let class_id_str: &str = str::from_utf8(class_id).unwrap_or_default();
            if !class_id_str.starts_with("PXEClient") {
                // class_id(opt 60) must start with PXEClient
                return None;
            }
            
            // Create and return a new response packet here

        }
        _ => None
    }
}

Okay that’s a fair bit… oh well now we just need a response to send back…

How hard can it be…

Well Jacob’s code in proxydhcp seems to hold the keys.

Basically we need to send a message with the following headers/options.

Headers:

Field Value Comment
Opcode 2 Boot Reply
xid * xid from request
flags * Broadcast true
ciaddr 0.0.0.0 Default for server
siaddr * IP address of the TFTP/HTTP server
chaddr * chaddr from request
sname * IP address of the TFTP/HTTP server

Options:

Tag Name Tag Number Length Data
Message Type 53 1 DHCP Offer
Server Identifier 54 4 IP address of the TFTP/HTTP server
Class Identifier 60 9 PXEClient
Bootfile Name 67 Varies name of bootfile or http url
Vendor Options 43 Varies Encapsulated options below
PXE_DISCOVERY_CONTROL 6 8 8 bytes set to 0

In rust with dhcproto that looks a little bit like this

info!(
    "Responding to {} ({:?},{}) with {} ({})",
    HexSlice::new(mac_address),
    request_architecture,
    request_user_class,
    redirect_to.to_string(),
    boot_file
);

let mut response = Message::default();
response
    .set_flags(Flags::default().set_broadcast())
    .set_chaddr(&mac_address)
    .set_xid(message.xid())
    .set_siaddr(redirect_to)
    .set_sname(redirect_to.to_string().as_bytes())
    .set_opcode(Opcode::BootReply)
    .opts_mut()
    .insert(DhcpOption::MessageType(MessageType::Offer));

let mut vendor_options: Vec<u8> = Vec::new();
vendor_options.push(6);                                     // Set Option 6
vendor_options.push(8);                                     // Length 8 Bytes
vendor_options.append(&mut vec![0, 0, 0, 0, 0, 0, 0, 0]);   // 8 Empty Bytes
vendor_options.push(255);                                   // PXEClient End

response
    .opts_mut()
    .insert(DhcpOption::VendorExtensions(vendor_options));

response
    .opts_mut()
    .insert(DhcpOption::ServerIdentifier(redirect_to));

response
    .opts_mut()
    .insert(DhcpOption::ClassIdentifier("PXEClient".as_bytes().to_vec()));

response
    .opts_mut()
    .insert(DhcpOption::BootfileName(boot_file.as_bytes().to_vec()));

Some(response)

Does it work? #

yes 🙂

Booting UEFI VM WinPE Screenshot
Booting UEFI VM WinPE Screenshot

Booting UEFI VM DHCP Logs
Booting UEFI VM DHCP Logs

rusted-pxe on da thinkpad?

WinPE on a Lenovo Laptop
rusted-pxe on da thinkpad!

What now? #

Well I am really happy with the progress so far but I want to see if I can stretch the limits here.

Ideas:

  1. Boot Menu with multiple options - linux, windows, recovery environments, etc.
  2. Automatically install windows - autounattend
  3. Maybe Automatically autopilot join laptops?

I’m open to hear more so hit me up on my socials below if you have any other ideas.

oh also I did the thing and its on github finally - jordangomes/rusted-pxe