Your first Zeek script doesn’t need to detect malware, stop threats, or solve a critical security problem. In fact, it probably shouldn’t.
David Fitz learned Zeek through a SANS course and wanted to try his first script in his homelab. Instead of detecting botnets or analyzing suspicious traffic, he extracted Steam IDs from video game sessions because it let him test something while having fun.
Sometimes the best first script is the one that helps you learn, not the one that protects your network. Here’s how David wrote his and how you can get started with yours.
Pick Something You Can Test
David chose Steam traffic for three reasons:
- It was available on his network
- Much of it would show up as unknown protocol traffic
- He could generate test data by doing something enjoyable (playing games)
“The time spent taking PCAPs for testing could be easily spent relaxing,” he explained. Instead of setting up complex test environments or waiting for specific traffic patterns, he could verify his script was working by launching a multiplayer game.
The traffic was partially plaintext, and David verified that the Steam IDs showed up in Wireshark before writing any code.
Expect Trial and Error (And That’s Fine)
David’s first approach didn’t work. He tried using signatures to identify and extract the Steam IDs, but quickly hit a wall.
Zeek’s signature framework only captures the first 1024 bytes of a session by default, and the Steam IDs appeared later in the packets. “My initial attempts at simple detection and tagging went out the window,” he said.
The solution came from the Zeek documentation. He found udp_content_deliver_all_orig and udp_content_deliver_all_resp. These are settings that make UDP content available to scripts even when there’s no protocol analyzer.
For a homelab, enabling all UDP collection was fine. For production environments, you’d want to enable specific ports only to avoid performance issues.
From there, it was trial and error. “I was just printing this out to the screen,” David said, “trying to get to a point where I could say, I’ve found a record, here’s the ID.”
David’s initial exploration looked like this, printing UDP payload data to see what he was working with:
# Allow examination of UDP data. redef udp_content_deliver_all_orig = T; redef udp_content_deliver_all_resp = T; # The udp_contents event allows you to access the UDP payload of packets making it easy to analyze UDP data. event udp_contents(c: connection, is_orig: bool, contents: string) { # This searches for the keyword “steamid:” in the UDP payload. if ( /steamid:/ in contents){ # Print out that we found the keyword along with IPs and ports print fmt ("SteamID Found: %s:%s -> %s:%s",c$id$orig_h, c$id$orig_p, c$id$resp_h, c$id$resp_p); # This prints the packet payload as a string representation of hexadecimal print fmt ("String Data: %s",contents); # Print raw bytes of each packet payload print fmt ("Raw Bytes: %s",string_to_ascii_hex(contents)); # Print the first 128 bytes of each payload print fmt ("First Bytes: %s",string_to_ascii_hex(contents)[0:128]); # Print the first 24 bytes of each payload. print string_to_ascii_hex(contents)[0:24]; } }
Output:
SteamID Found: 10.10.30.150:50593/udp -> 162.254.192.73:27018/udp
String Data: \x15\x19\xf4\x9c\xc9\x03\x01\x00\x10\x01-\x8cy\xac85\x01\x9e\x09\x19:\x19steamid:76561198023810000
Raw Bytes: 1519f49cc903010010012d8c79ac3835019e09193a19737465616d69643a3736353631313938303233383130323932
First Bytes: 1519f49cc903010010012d8c79ac3835019e09193a19737465616d69643a3736353631313938303233383130323932
1519f49cc903010010012d8c
Steam ID: 76561198023810000
First Bytes: 1519f49cc903010010012d8
SteamID Found: 10.10.30.79:33708/udp -> 162.254.192.68:27048/udp
String Data: \x14\x19\xb3\x9d\xe5E\x01\x00\x10\x01 \x01*7Timeout; remote problem. Rx age server 12.7s relay 0.1s0\xa1\x1f=\x01\x9e\x09\x19E\x
8cy\xac8Z0b\x97\xc9\x8e\xc2\x9e\xedEJ\x1b\xb2\x96Wm+tcDV\x91\xa2\xf0\xaarS\xe8\xdd2qP\xaak\xa5\xf5\x8d\x99\xa4w\xf0_-\xdd*?\x0f\x87J1`\x07j\x
0a\x81\x01\xb3\x9d\xe5E\x01\x00\x10\x01z\x19steamid:76561199132940000\x8a\x01k\x12i\x10\xe0\xca\xd1\x88\x04\x18\xe0, \xcf\x0f(\xcb&0\xc0\x038
\xca&@\x00H\x00P\x00X\x00x\x00\xa8\x01{\xe8\x01\x01\xf0\x01d\xf8\x01d\x80\x02d\x88\x02d\xc8\x02s\xd0\x02\x01\xd8\x02\x04\xe0\x02\x01\xe8\x02\
x04\xf0\x02\x01\x90\x03\x0c\x98\x03\x10\xa0\x03\x11\xa8\x03C\xb0\x03o\xe8\x03\xd9\x0b\xf0\x03\x9a\x0a\xf8\x03\x84\x0a\x80\x04\xfe\x02\x88\x04
t\x90\x04\xaa\x01\x92\x01}\x12{\x10\x8d\x05\x18\xb3/ \xf1\x0f(\xb7&0\xc7\x038\xb7&@\x13H\x00P\x00X\x00x\x00\xa8\x01m\xb0\x01\x01\xb8\x01\x0a\
xc0\x01\x02\xc8\x01\x02\xe8\x01\x02\xf0\x01\\xf8\x01a\x80\x02d\x88\x02d\xc8\x02!\xd0\x02\xf9\x1f\xd8\x02\xe9\x01\xe0\x02}\xe8\x02<\xf0\x02%\x
f8\x02\x13\x80\x03\x07\x90\x03\x1b\x98\x03"\xa0\x03(\xa8\x03N\xb0\x03u\xe8\x03\xb7\x0a\xf0\x03\xc1\x08\xf8\x03\xab\x0a\x80\x04\xb5\x03\x88\x0
4\x9d\x01\x90\x04\xdb\x02\x98\x01\x00\xaa\x015\x12\x038\x87'\x1a.\x08\x1a\x10\x0f\x18\x0b &-dai\x005dai\x008\xfd\x02@\x8d\x05X\x19`\x0fh\x0ap
%}dai\x00\x85\x01dai\x00\x88\x01\x1c\xb0\x01\x00
Raw Bytes: 1419b39de5450100100120012a3754696d656f75743b2072656d6f74652070726f626c656d2e20527820616765207365727665722031322e37732072656c617920
302e317330a11f3d019e0919458c79ac385a306297c98ec29eed454a1bb296576d2b7463445691a2f0aa7253e8dd327150aa6ba5f58d99a477f05f2ddd2a3f0f874a3160076a0
a8101b39de545010010017a19737465616d69643a37363536313139393133323934313700008a016b126910e0cad1880418e02c20cf0f28cb2630c00338ca2640004800500058
007800a8017be80101f00164f80164800264880264c80273d00201d80204e00201e80204f0020190030c980310a00311a80343b0036fe803d90bf0039a0af803840a8004fe028
804749004aa0192017d127b108d0518b32f20f10f28b72630c70338b72640134800500058007800a8016db00101b8010ac00102c80102e80102f0015cf80161800264880264c8
0221d002f91fd802e901e0027de8023cf00225f8021380030790031b980322a00328a8034eb00375e803b70af003c108f803ab0a8004b50388049d019004db02980100aa01351
2033887271a2e081a100f180b20262d64616900356461690038fd02408d055819600f680a70257d6461690085016461690088011cb00100
First Bytes: 1419b39de5450100100120012a3754696d656f75743b2072656d6f74652070726f626c656d2e20527820616765207365727665722031322e37732072656c6179
1419b39de545010010012001
This approach let David see the raw packet data in multiple formats to identify patterns and find where the Steam IDs appeared in the payload.
Eventually, he figured out how to select the specific bytes he needed. The final working script identifies packets containing Steam IDs by matching their leading bytes:
redef udp_content_delivery_ports_use_resp = T; # Add two fields into the conn log for local testing redef record Conn::Info += { steamid_active: string &log &optional; steamid_other: string &log &optional; }; #The working one event udp_contents(c: connection, is_orig: bool, contents: string) { if ( /steamid:/ in contents){ print fmt ("SteamID Found: %s:%s -> %s:%s",c$id$orig_h, c$id$orig_p, c$id$resp_h, c$id$resp_p); #print fmt ("String Data: %s",contents); #print fmt ("Raw Bytes: %s",string_to_ascii_hex(contents)); #print fmt ("First Bytes: %s",string_to_ascii_hex(contents)[0:128]); #print string_to_ascii_hex(contents)[0:24]; # Each of these if statements select the first bytes of a different packet type. In testing these appear semi static. if (string_to_ascii_hex(contents)[0:4] == "180a"){ print fmt ("Steam ID: %s", contents[87:104]); print ("Probable active user"); print fmt("First Bytes: %s", string_to_ascii_hex(contents)[0:23]); # Add the steamid to the conn log c$conn$steamid_active = contents[87:104]; } # This next section searches for additional SteamIDs that are usually an another player. if (c$conn$steamid_active != ""){ if (string_to_ascii_hex(contents)[0:6] == "1519b3"){ print fmt ("Steam ID: %s", contents[32:49]); print fmt("First Bytes: %s", string_to_ascii_hex(contents)[0:23]); if (contents[32:49] != c$conn$steamid_active) c$conn$steamid_other = contents[32:49]; } if (string_to_ascii_hex(contents)[0:6] == "1519f4"){ print fmt ("Steam ID: %s", contents[30:47]); print fmt("First Bytes: %s", string_to_ascii_hex(contents)[0:23]); if (contents[30:47] != c$conn$steamid_active) c$conn$steamid_other = contents[30:47]; } if (string_to_ascii_hex(contents)[0:4] == "1419"){ print fmt ("Steam ID: %s", contents[130:147]); print fmt("First Bytes: %s", string_to_ascii_hex(contents)[0:23]); if (contents[130:147] != c$conn$steamid_active) c$conn$steamid_other = contents[130:147]; } } } }
Output:
SteamID Found: 10.10.30.150:50593/udp -> 162.254.192.73:27018/udp
String Data: \x15\x19\xf4\x9c\xc9\x03\x01\x00\x10\x01-\x8cy\xac85\x01\x9e\x09\x19:\x19steamid:76561198023810000
Raw Bytes: 1519f49cc903010010012d8c79ac3835019e09193a19737465616d69643a3736353631313938303233383130323932
First Bytes: 1519f49cc903010010012d8c79ac3835019e09193a19737465616d69643a3736353631313938303233383130323932
1519f49cc903010010012d8c
Steam ID: 76561198023810000
First Bytes: 1519f49cc903010010012d8
SteamID Found: 10.10.30.79:33708/udp -> 162.254.192.68:27048/udp
String Data: \x14\x19\xb3\x9d\xe5E\x01\x00\x10\x01 \x01*7Timeout; remote problem. Rx age server 12.7s relay 0.1s0\xa1\x1f=\x01\x9e\x09\x19E\x
8cy\xac8Z0b\x97\xc9\x8e\xc2\x9e\xedEJ\x1b\xb2\x96Wm+tcDV\x91\xa2\xf0\xaarS\xe8\xdd2qP\xaak\xa5\xf5\x8d\x99\xa4w\xf0_-\xdd*?\x0f\x87J1`\x07j\x
0a\x81\x01\xb3\x9d\xe5E\x01\x00\x10\x01z\x19steamid:76561199132940000\x8a\x01k\x12i\x10\xe0\xca\xd1\x88\x04\x18\xe0, \xcf\x0f(\xcb&0\xc0\x038
\xca&@\x00H\x00P\x00X\x00x\x00\xa8\x01{\xe8\x01\x01\xf0\x01d\xf8\x01d\x80\x02d\x88\x02d\xc8\x02s\xd0\x02\x01\xd8\x02\x04\xe0\x02\x01\xe8\x02\
x04\xf0\x02\x01\x90\x03\x0c\x98\x03\x10\xa0\x03\x11\xa8\x03C\xb0\x03o\xe8\x03\xd9\x0b\xf0\x03\x9a\x0a\xf8\x03\x84\x0a\x80\x04\xfe\x02\x88\x04
t\x90\x04\xaa\x01\x92\x01}\x12{\x10\x8d\x05\x18\xb3/ \xf1\x0f(\xb7&0\xc7\x038\xb7&@\x13H\x00P\x00X\x00x\x00\xa8\x01m\xb0\x01\x01\xb8\x01\x0a\
xc0\x01\x02\xc8\x01\x02\xe8\x01\x02\xf0\x01\\xf8\x01a\x80\x02d\x88\x02d\xc8\x02!\xd0\x02\xf9\x1f\xd8\x02\xe9\x01\xe0\x02}\xe8\x02<\xf0\x02%\x
f8\x02\x13\x80\x03\x07\x90\x03\x1b\x98\x03"\xa0\x03(\xa8\x03N\xb0\x03u\xe8\x03\xb7\x0a\xf0\x03\xc1\x08\xf8\x03\xab\x0a\x80\x04\xb5\x03\x88\x0
4\x9d\x01\x90\x04\xdb\x02\x98\x01\x00\xaa\x015\x12\x038\x87'\x1a.\x08\x1a\x10\x0f\x18\x0b &-dai\x005dai\x008\xfd\x02@\x8d\x05X\x19`\x0fh\x0ap
%}dai\x00\x85\x01dai\x00\x88\x01\x1c\xb0\x01\x00
Raw Bytes: 1419b39de5450100100120012a3754696d656f75743b2072656d6f74652070726f626c656d2e20527820616765207365727665722031322e37732072656c617920
302e317330a11f3d019e0919458c79ac385a306297c98ec29eed454a1bb296576d2b7463445691a2f0aa7253e8dd327150aa6ba5f58d99a477f05f2ddd2a3f0f874a3160076a0
a8101b39de545010010017a19737465616d69643a37363536313139393133323934313700008a016b126910e0cad1880418e02c20cf0f28cb2630c00338ca2640004800500058
007800a8017be80101f00164f80164800264880264c80273d00201d80204e00201e80204f0020190030c980310a00311a80343b0036fe803d90bf0039a0af803840a8004fe028
804749004aa0192017d127b108d0518b32f20f10f28b72630c70338b72640134800500058007800a8016db00101b8010ac00102c80102e80102f0015cf80161800264880264c8
0221d002f91fd802e901e0027de8023cf00225f8021380030790031b980322a00328a8034eb00375e803b70af003c108f803ab0a8004b50388049d019004db02980100aa01351
2033887271a2e081a100f180b20262d64616900356461690038fd02408d055819600f680a70257d6461690085016461690088011cb00100
First Bytes: 1419b39de5450100100120012a3754696d656f75743b2072656d6f74652070726f626c656d2e20527820616765207365727665722031322e37732072656c6179
1419b39de545010010012001
The script checks for different packet types based on their leading bytes (like “180a”, “1519b3”, “1519f4”, “1419”) and extracts Steam IDs from specific byte positions in each type. It can identify both the active user and other players in the session. Note the third output: the script occasionally selects unknown hex data when leading bytes aren’t as static as expected.
The script wasn’t perfect. David describes it as “relatively delicate.” Not all of the leading bytes were as static as originally thought, and the script occasionally selects unknown hex data instead of valid Steam IDs (see the third packet in the example above). A protocol analyzer would produce better results, but this approach worked well enough for learning.
Where to Actually Start
If you’re ready to write your first script, here’s a concrete path forward:
1. Pick Your First Project
Choose traffic you can generate on demand. David picked Steam gaming traffic. You might choose:
- A specific website you visit regularly
- A mobile app you use
- A streaming service
- A game or voice chat application
Make sure you can generate it on demand and verify the data exists (David checked in Wireshark first).
2. Start With Simple Detection
Don’t try to extract complex data right away. Your first goal is to detect when that traffic happens and log it.
Here’s a simple example that detects HTTPS traffic to domains containing “zeek”:
# There are lots of fun SSL (Also works on TLS) event types to pick from. event ssl_extension_server_name(c: connection, is_client: bool, names: string_vec) { for ( name in names) { if ( “zeek” in name) { print fmt ("Found the domain: %s ", names); } } }
Output:
Found the domain: zeek.org
This shows how you can hook into existing Zeek events (like ssl_extension_server_name) without needing to parse protocols yourself.
Or a simplified version of David’s Steam ID detection:
redef udp_content_deliver_all_orig = T; redef udp_content_deliver_all_resp = T; event udp_contents(c: connection, is_orig: bool, contents: string) { if ( /steamid:/ in contents){ print fmt ("SteamID Found: %s:%s -> %s:%s",c$id$orig_h, c$id$orig_p, c$id$resp_h, c$id$resp_p); } }
This enables UDP content delivery and prints whenever a packet contains “steamid:” in the payload. Simple detection before you try extracting specific values.
3. Use These Resources
Start with the tutorial, then reference the docs as you write. When you get stuck, search the documentation or ask in the Zeek Community Slack or on Discourse. Include your code and describe what’s not working.
4. Follow a Trial-and-Error Approach
When David was figuring out Steam ID extraction, his process was:
- Print everything to the screen to see what data he’s working with
- Iterate one small step at a time
- Test frequently
- Don’t worry about efficiency (worry about seeing results first!)
If you’re just getting started, your first Zeek script should prioritize learning over solving real problems. Pick something you can test easily, expect trial and error, and don’t worry about whether it’s useful from a security standpoint.
David’s Steam ID script wasn’t perfect, wasn’t particularly useful, and isn’t something he’d deploy in production. But it taught him about working with unknown protocols, accessing UDP content, and troubleshooting Zeek scripts.
If you write your first script (or have questions while figuring it out), share it with us in Slack or Discourse. We’d love to see what you’re working on, even if it’s not perfect.
RSS - Posts