⟵ Home

Tickling a Sun Ray 2 - DHCP Discovery

December 13, 2025 ∙ 24 minute read

This Christmas I just got from my partner a brand new, still sealed, Sun Ray 2. Basically the best gift I ever got. Period.

The Ray is a thin client that connects to a remote Sun server. I already ordered a Sun Fire, but until it arrives, I decided to poke the little thing and see what makes it tick!

After powering on, and having a network cable connected, the Ray shows a little screen with its MAC address, the network cable speed, and if it’s reached a local server. Since the Sun Fire is not here, the last part just does not happen, so let’s begin by observing how it tries to reach the server.

DHCPDISCOVER

The Ray starts with a DHCPDISCOVER to try to reach its server, which is no big surprise:

0000   ff ff ff ff ff ff 00 14 4f 85 f2 0b 08 00 45 00   ........O.....E.
0010   01 48 00 28 40 00 ff 11 7a 7d 00 00 00 00 ff ff   .H.(@...z}......
0020   ff ff 00 44 00 43 01 34 00 00 01 01 06 00 ff 4e   ...D.C.4.......N
0030   c3 a7 04 7e 00 00 00 00 00 00 00 00 00 00 00 00   ...~............
0040   00 00 00 00 00 00 00 14 4f 85 f2 0b 00 00 00 00   ........O.......
0050   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0060   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0070   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0080   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0090   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
00a0   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
00b0   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
00c0   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
00d0   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
00e0   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
00f0   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0100   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0110   00 00 00 00 00 00 63 82 53 63 35 01 01 3c 0e 53   ......c.Sc5..<.S
0120   55 4e 57 2e 4e 65 77 54 2e 53 55 4e 57 3d 07 01   UNW.NewT.SUNW=..
0130   00 14 4f 85 f2 0b ff 00 00 00 00 00 00 00 00 00   ..O.............
0140   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0150   00 00 00 00 00 00                                 ......

DIX / Ethernet II

The first portion is a classic DIX Ethernet frame (or Ethernet II, if you’re still litigating DIX credit). From 0x00 to 0x05 we have the broadcast address ff:ff:ff:ff:ff:ff, the second one is the Ray’s address, 00:14:4f:85:f2:0b, 0x0C to 0x0D indicates an IPv4 packet (0x0800).

IPv4

The second portion, as expected, is the IPv4 header. Nothing new there as well, the only relevant information there is that the destination address is 255.255.255.255, and it’s a UDP packet; this basically covers bytes 0x0E to 0x21.

UDP

From 0x22 to 0x29 we can observe the UDP header. Interesting points are source port 68, and destination port 67.

BOOTP/DHCP fixed header + DHCP options

From 0x2A onwards we have the DHCPDISCOVER payload. Finally something interesting:

So what’s interesting so far?

The big oddity is what’s missing: Option 55 (Parameter Request List). Most DHCP clients use it to ask for things like DNS servers, routes, NTP, etc. The Sun Ray doesn’t ask, it just identifies itself (Vendor Class + Client ID) and waits to see what comes back. That suggests this client either runs with strong defaults, or expects a Sun-aware environment to “just know” what to provide.

Also: SUNW.NewT.SUNW looks like a Sun vendor-class marker; possibly a New Terminal generation marker rather than a generic Sun Ray family identifier.

This is the first part of the whole negotiation, and lots can already be observed and speculated!

Now, let’s make something that replies the Ray as it expects. I could just leverage dnsmasq for that, but let’s go the fun way(tm) and write a bit of C:

ray_dhcp.c

//
// Created by vitosartori on 12/13/25.
//

// Minimal DHCP responder (DISCOVER->OFFER, REQUEST->ACK) for RE labs.
// Build:  gcc -O2 -Wall -Wextra -o ray_dhcp ray_dhcp.c
// Run:    sudo ./ray_dhcp enp2s0 192.168.100.1 192.168.100.50 255.255.255.0 192.168.100.1

#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>

enum {
    BOOTREQUEST = 1,
    BOOTREPLY   = 2,

    HTYPE_ETHERNET = 1,
    HLEN_ETHERNET  = 6,

    DHCP_COOKIE = 0x63825363,

    // DHCP Options

    OPT_PAD        = 0,
    OPT_SUBNETMASK = 1,
    OPT_ROUTER     = 3,
    OPT_REQ_IP     = 50,
    OPT_LEASETIME  = 51,
    OPT_MSGTYPE    = 53,
    OPT_SERVERID   = 54,
    OPT_VENDORCLASS= 60,
    OPT_CLIENTID   = 61,
    OPT_END        = 255,

    // DHCP Message Types

    DHCPDISCOVER = 1,
    DHCPOFFER    = 2,
    DHCPREQUEST  = 3,
    DHCPACK      = 5
};

#pragma pack(push, 1)
typedef struct {
    uint8_t  op;
    uint8_t  htype;
    uint8_t  hlen;
    uint8_t  hops;
    uint32_t xid;
    uint16_t secs;
    uint16_t flags;
    uint32_t ciaddr;
    uint32_t yiaddr;
    uint32_t siaddr;
    uint32_t giaddr;
    uint8_t  chaddr[16];
    uint8_t  sname[64];
    uint8_t  file[128];
    // Following is cookie + TLVs
} bootp_t;
#pragma pack(pop)

typedef struct {
    uint8_t  msgtype;       // Option 53
    uint8_t  has_msgtype;
    uint8_t  vendor[256];   // Option 60
    uint8_t  vendor_len;
    uint8_t  has_vendor;
    uint32_t req_ip;        // Option 50 (optional)
    uint8_t  has_req_ip;
} dhcp_opts_t;

static void die(const char *msg) {
    perror(msg);
    exit(1);
}

static int parse_ipv4(const char *s, uint32_t *out_nbo) {
    struct in_addr a;
    if (inet_pton(AF_INET, s, &a) != 1) return 0;
    *out_nbo = a.s_addr;
    return 1;
}

static void print_mac(const uint8_t mac[6], char *buf, const size_t buf_len) {
    snprintf(buf, buf_len, "%02x:%02x:%02x:%02x:%02x:%02x",
             mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}

static int parse_dhcp_options(const uint8_t *p, const size_t n, dhcp_opts_t *o) {
    memset(o, 0, sizeof(*o));

    // First the cookie
    if (n < 4) return 0;
    uint32_t cookie;
    memcpy(&cookie, p, 4);
    if (ntohl(cookie) != DHCP_COOKIE) {
        fprintf(stderr, "drop: cookie mismatch (got %08x)\n", ntohl(cookie));
        return 0;
    }

    size_t i = 4;

    while (i < n) {
        const uint8_t code = p[i++];
        if (code == OPT_PAD) continue;
        if (code == OPT_END) break;
        if (i >= n) break;

        const uint8_t len = p[i++];
        if (i + len > n) break;
        const uint8_t *val = p + i;

        if (code == OPT_MSGTYPE && len == 1) {
            o->msgtype = val[0];
            o->has_msgtype = 1;
        } else if (code == OPT_VENDORCLASS && len > 0 && len <= 255) {
            memcpy(o->vendor, val, len);
            o->vendor[len] = 0;
            o->vendor_len = len;
            o->has_vendor = 1;
        } else if (code == OPT_REQ_IP && len == 4) {
            memcpy(&o->req_ip, val, 4);
            o->has_req_ip = 1;
        }

        i += len;
    }

    return 1;
}

static uint8_t *opt_put(uint8_t *w, const uint8_t code, const void *val, const uint8_t len) {
    *w++ = code;
    *w++ = len;
    memcpy(w, val, len);
    return w + len;
}

static size_t build_reply(uint8_t *out,
                          const size_t outcap,
                          const bootp_t *req,
                          const dhcp_opts_t *reqo,
                          const uint8_t dhcp_type,
                          const uint32_t server_ip,
                          const uint32_t yiaddr,
                          const uint32_t subnet_mask,
                          const uint32_t router_ip,
                          const uint32_t lease_seconds) {
    if (outcap < sizeof(bootp_t) + 4 + 64) return 0;

    bootp_t rep = {0};

    rep.op = BOOTREPLY;
    rep.htype = req->htype;
    rep.hlen = req->hlen;
    rep.hops = 0;
    rep.xid = req->xid;
    rep.secs = 0;
    rep.flags = req->flags; // Echo client flags

    rep.ciaddr = 0;
    rep.yiaddr = yiaddr; // Offered/acked IP
    rep.siaddr = server_ip; // the mothership hint
    rep.giaddr = 0;

    memcpy(rep.chaddr, req->chaddr, 16);

    memcpy(out, &rep, sizeof(rep));
    uint8_t *w = out + sizeof(rep);

    const uint32_t cookie = htonl(DHCP_COOKIE);
    memcpy(w, &cookie, 4);
    w += 4;

    // Options
    w = opt_put(w, OPT_MSGTYPE, &dhcp_type, 1);
    w = opt_put(w, OPT_SERVERID, &server_ip, 4);

    const uint32_t lt = htonl(lease_seconds);
    w = opt_put(w, OPT_LEASETIME, &lt, 4);

    w = opt_put(w, OPT_SUBNETMASK, &subnet_mask, 4);
    w = opt_put(w, OPT_ROUTER, &router_ip, 4);

    // Optional: echo vendor class back, may help with “vendor-aware feel”
    if (reqo->has_vendor && reqo->vendor_len > 0) {
        w = opt_put(w, OPT_VENDORCLASS, reqo->vendor, reqo->vendor_len);
    }

    *w++ = OPT_END;

    return (size_t) (w - out);
}

int main(const int argc, char **argv) {
    if (argc != 6) {
        fprintf(stderr,
            "Usage: %s <ifname> <server_ip> <offer_ip> <subnet_mask> <router_ip>\n"
            "Example: %s enp2s0 192.168.100.1 192.168.100.50 255.255.255.0 192.168.100.1\n",
            argv[0], argv[0]);
        return 2;
    }

    const char *ifname = argv[1];
    uint32_t server_ip, offer_ip, mask_ip, router_ip;

    if (!parse_ipv4(argv[2], &server_ip) ||
        !parse_ipv4(argv[3], &offer_ip)  ||
        !parse_ipv4(argv[4], &mask_ip)   ||
        !parse_ipv4(argv[5], &router_ip)) {
        fprintf(stderr, "Invalid IPv4 argument\n");
        return 2;
    }

    const int s = socket(AF_INET, SOCK_DGRAM, 0);
    if (s < 0) die("socket");

    const int yes = 1;
    if (setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) < 0) die("setsockopt REUSEADDR");
    if (setsockopt(s, SOL_SOCKET, SO_BROADCAST, &yes, sizeof(yes)) < 0) die("setsockopt BROADCAST");

    // Bind to port 67 on all addresses
    struct sockaddr_in bindaddr = {0};
    bindaddr.sin_family = AF_INET;
    bindaddr.sin_port = htons(67);
    bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(s, (struct sockaddr*)&bindaddr, sizeof(bindaddr)) < 0) {
        fprintf(stderr, "bind(:67) failed: %s\n", strerror(errno));
        fprintf(stderr, "Tip: run as root or grant cap_net_bind_service.\n");
        return 1;
    }

    // Pin to interface so we don't respond on the wrong NIC.
    if (setsockopt(s, SOL_SOCKET, SO_BINDTODEVICE, ifname, strlen(ifname) + 1) < 0) {
        fprintf(stderr, "SO_BINDTODEVICE(%s) failed: %s\n", ifname, strerror(errno));
        return 1;
    }

    fprintf(stderr, "Listening on %s UDP/67. server=%s offer=%s\n", ifname, argv[2], argv[3]);

    uint8_t inbuf[2048];
    uint8_t outbuf[2048];

    for (;;) {
        struct sockaddr_in src;
        socklen_t srclen = sizeof(src);
        const ssize_t n = recvfrom(s, inbuf, sizeof(inbuf), 0, (struct sockaddr*)&src, &srclen);
        if (n < 0) {
            if (errno == EINTR) continue;
            die("recvfrom");
        }
        fprintf(stderr, "got %zd bytes from %s:%d\n",
        n, inet_ntoa(src.sin_addr), ntohs(src.sin_port));
        fflush(stderr);
        enum { BOOTP_FIXED_LEN = 236 };

        if ((size_t)n < BOOTP_FIXED_LEN + 4) continue;
        const size_t opt_len = (size_t)n - BOOTP_FIXED_LEN;

        const bootp_t *req = (const bootp_t *)inbuf; // prolly OK for chaddr/xid/etc.
        if (req->op != BOOTREQUEST) { fprintf(stderr,"drop: not BOOTREQUEST\n"); continue; }
        if (req->htype != HTYPE_ETHERNET) { fprintf(stderr,"drop: htype=%u\n", req->htype); continue; }
        if (req->hlen != HLEN_ETHERNET) { fprintf(stderr,"drop: hlen=%u\n", req->hlen); continue; }

        // Options start at offset 236
        dhcp_opts_t o = {0};
        if (!parse_dhcp_options(inbuf + BOOTP_FIXED_LEN, opt_len, &o)) continue;
        if (!o.has_msgtype) {
            fprintf(stderr,"drop: no option 53\n");
            continue;
        }

        char macs[32];
        print_mac(req->chaddr, macs, sizeof(macs));

        fprintf(stderr, "opts[0..15]: ");
        for (int i = 0; i < 16 && (size_t)i < opt_len; i++) fprintf(stderr, "%02x ", inbuf[BOOTP_FIXED_LEN + i]);
        fprintf(stderr, "\n");

        uint8_t reply_type = 0;
        if (o.msgtype == DHCPDISCOVER) reply_type = DHCPOFFER;
        else if (o.msgtype == DHCPREQUEST) reply_type = DHCPACK;
        else {
            fprintf(stderr, "drop: Unknown msgtype 0x%02x\n", o.msgtype);
            continue;
        }

        // Honor Option 50 Requested IP if present.
        uint32_t yiaddr = offer_ip;
        if (o.has_req_ip) yiaddr = o.req_ip;

        size_t outlen = build_reply(outbuf, sizeof(outbuf), req, &o, reply_type,
                                    server_ip, yiaddr, mask_ip, router_ip, 3600);
        if (!outlen) continue;

        // Send to broadcast:68, as we noticed the client doesn't have an IP
        struct sockaddr_in dst = {0};
        dst.sin_family = AF_INET;
        dst.sin_port = htons(68);
        dst.sin_addr.s_addr = htonl(INADDR_BROADCAST);

        if (sendto(s, outbuf, outlen, 0, (struct sockaddr*)&dst, sizeof(dst)) < 0) {
            fprintf(stderr, "sendto failed: %s\n", strerror(errno));
            continue;
        }

        fprintf(stderr, "Sent %lu bytes\n", outlen);

        if (o.has_vendor) {
            fprintf(stderr, "%s xid=%08x -> %s (vendor=%s)\n",
                    macs, ntohl(req->xid),
                    (reply_type == DHCPOFFER) ? "OFFER" : "ACK",
                    (const char *)o.vendor);
        } else {
            fprintf(stderr, "%s xid=%08x -> %s\n",
                    macs, ntohl(req->xid),
                    (reply_type == DHCPOFFER) ? "OFFER" : "ACK");
        }
    }
}

This replies with what the Ray expects, offering it an IP leased from the Linux box. I only forgot to mention how things are wired here at my lab:

Sun Ray ↔ USB Ethernet ↔ Linux box (DHCP responder) ↔ rest of LAN

So the magic is happening on interface enx00e04c3f6690, I’ll give myself an IP 192.168.100.1, and give 192.168.100.50 to the Ray. First, let’s get an IP on that interface:

sudo ip addr add 192.168.100.1/24 dev enx00e04c3f6690
sudo ip link set enx00e04c3f6690 up

And finally compile and run (as sudo). The result is positive! The Ray updated the UI indicating it has communicated with the server! Then, a wild UDP packet appears:

0000   ff ff ff ff ff ff 00 14 4f 85 f2 0b 08 00 45 00   ........O.....E.
0010   00 27 00 06 40 00 40 11 15 e6 c0 a8 64 32 ff ff   .'..@[email protected]..
0020   ff ff 1b 61 1b 61 00 13 00 00 73 65 72 76 65 72   ...a.a....server
0030   51 3d 31 0a 00 00 00 00 00 00 00 00               Q=1.........

Ignoring the framing information and other headers we get 11 payload bytes plus some padding. The payload is:

0000 73 65 72 76 65 72 51 3d 31 0a 00                   serverQ=1..

That’s… Literally serverQ=1\n\0. But there’s more! Here’s what the packet gives:

  1. Both source and destination ports are 7009
  2. The source IP is the IP we gave to the Ray! (I omitted a few DHCPACK and DHCPINFORM packets)
  3. The destination IP is STILL the broadcast address

This might be Sun Ray’s appliance protocol territory (UDP/7009 shows up in Sun Ray land), but I’m treating it as an unknown for now. The protocol is proprietary and not documented, but let’s wrap up for now and just be happy for the little, still important, progress.

Next: what happens if we answer serverQ=1?