Ether Tag : HackTheBox ICS Challenge WriteUp
Challenge Overview
- Name: Ether Tag
- Difficulty: Very Easy
- Category: Industrial Control Systems (ICS)
- Protocol: EtherNet/IP (CIP)
- Objective: Retrieve the value of the
FLAGtag This challenge focuses on understanding and interacting with the EtherNet/IP protocol, not on exploiting a vulnerability.
Background: EtherNet/IP and CIP
EtherNet/IP is an industrial protocol built on TCP/UDP and uses the Common Industrial Protocol (CIP) for communication.
CIP is object-oriented.
All data is accessed using the following model:
Service + Path
Path = Class β Instance β Attribute
There are:
- no files
- no endpoints
- no URLs Only objects and attributes.
Black-Box Mindset
From a black-box perspective, we know only:
- IP address and port
- The service is EtherNet/IP
- We must βretrieve the value of the FLAG tagβ
Step 2: Understand the CIP Object Model
CIP devices expose data through classes. Some standard classes: CIP defines a set of standard objects that every compliant device must expose.
According to ODVA and public vendor documentation:
Common CIP Objects
| Class ID | Object Name | Required |
|---|---|---|
| 0x01 | Identity Object | β Yes |
| 0x02 | Message Router Object | β Yes |
| 0x05 | CONNECTION OBJECT | β Yes |
| 0x04 | Assembly Object | β Optional |
Source:
https://www.rtautomation.com/technologies/ethernetip/
Step 3 β Identity Object Enumeration (Class 1)
Why 1 / 1 / 7?
This is defined by the CIP specification, not guessed.
- Class 1 β Identity Object
- Instance 1 β Device identity
- Attribute 7 β Product Name
Identity Object Attributes (Standard)
| Attribute ID | Description |
|---|---|
| 1 | Vendor ID |
| 2 | Device Type |
| 3 | Product Code |
| 4 | Revision |
| 5 | Status |
| 6 | Serial Number |
| 7 | Product Name |
This makes 1 / 1 / 7 the standard first read in ICS reconnaissance. |
Referance : 2.5.1. Identity Object (Class ID: 0x01)
Read Identity Product Name (1/1/7)
To Read this i have made the following script :
#!/usr/bin/env python3
import socket, struct
IP="<IP>"
PORT=<PORT>
def enip_hdr(cmd, length, session=0):
return struct.pack("<HHII8sI", cmd, length, session, 0, b"\x00"*8, 0)
def register_session(s):
payload = struct.pack("<HH", 1, 0)
s.sendall(enip_hdr(0x65, len(payload)) + payload)
r = s.recv(1024)
return struct.unpack_from("<I", r, 4)[0]
def cip_get_attr_single(c, i, a):
path = bytes([0x20, c, 0x24, i, 0x30, a])
return bytes([0x0E, len(path)//2]) + path
def send_rr_data(s, session, cip):
cpf = struct.pack("<IHH", 0, 0, 2)
cpf += struct.pack("<HH", 0x0000, 0)
cpf += struct.pack("<HH", 0x00B2, len(cip)) + cip
s.sendall(enip_hdr(0x6F, len(cpf), session) + cpf)
return s.recv(8192)
def extract_cip_data(enip):
payload = enip[24:]
off = 8
item_count = struct.unpack_from("<H", payload, 6)[0]
data_item = None
for _ in range(item_count):
it, ln = struct.unpack_from("<HH", payload, off)
off += 4
item = payload[off:off+ln]
off += ln
if it in (0x00B1, 0x00B2):
data_item = item
status = data_item[2]
data = data_item[4:]
return status, data
with socket.socket() as s:
s.connect((IP, PORT))
sid = register_session(s)
cip = cip_get_attr_single(1,1,7)
resp = send_rr_data(s, sid, cip)
st, data = extract_cip_data(resp)
print(data[1:1+data[0]].decode())
Result
1756-L61/B LOGIX5561
This confirms:
- EtherNet/IP communication works
- The device emulates a Logix-style PLC
- Explicit messaging is allowed
EtherNet/IP Communication Flow (High Level)
- Before any CIP message can be sent, EtherNet/IP requires a session.
Client
|
| RegisterSession (0x65)
|
| SendRRData (0x6F)
| ββ CIP: Get_Attribute_Single
|
PLC
- Building a CIP Read Request
This creates a CIP
Get_Attribute_Singlerequest:
0x0Eβ Get_Attribute_Single0x20β Class segment0x24β Instance segment0x30β Attribute segment
- Wrapping CIP Inside CPF (
SendRRData) CPF (Common Packet Format) is used to transport CIP messages.
- Item 0: Null Address
- Item 1: Unconnected Data (
0x00B2) containing CIP
- Parsing the CIP Reply
FLAG IS Hidden in Message Router Object (Class 2)
Why Class 2?
From the CIP specification:
βThe Message Router Object provides a mechanism for external devices to access objects in a CIP device.β
- Exists on all CIP devices
- Rarely inspected manually
- Can return arbitrary data
Source:
https://www.rtautomation.com/technologies/ethernetip/
Locate the Flag
We enumerate:
Class: 2 Instance: 1 Attribute: 1
#!/usr/bin/env python3
import socket, struct, textwrap
IP="94.237.61.52"
PORT=40957
def hexdump(b: bytes, width=16):
out=[]
for i in range(0, len(b), width):
chunk=b[i:i+width]
hexs=" ".join(f"{x:02x}" for x in chunk)
asc="".join(chr(x) if 32 <= x < 127 else "." for x in chunk)
out.append(f"{i:04x} {hexs:<{width*3}} {asc}")
return "\n".join(out)
def parse_enip_header(pkt: bytes):
if len(pkt) < 24:
return None
cmd, length, session, status = struct.unpack_from("<HHII", pkt, 0)
sender_ctx = pkt[12:20]
options = struct.unpack_from("<I", pkt, 20)[0]
return {
"command": cmd,
"length": length,
"session": session,
"status": status,
"sender_context": sender_ctx,
"options": options,
}
def enip_hdr(cmd, length, session=0, sender_ctx=b"\x00"*8):
return struct.pack("<HHII8sI", cmd, length, session, 0, sender_ctx, 0)
def register_session(s):
payload = struct.pack("<HH", 1, 0)
pkt = enip_hdr(0x65, len(payload)) + payload
print("[*] -> RegisterSession (0x65)")
print("[*] ENIP packet:\n" + hexdump(pkt))
s.sendall(pkt)
r = s.recv(2048)
print("[*] <- RegisterSession reply")
print("[*] Reply:\n" + hexdump(r))
h = parse_enip_header(r)
if not h:
raise RuntimeError("Bad ENIP reply")
if h["status"] != 0:
raise RuntimeError(f"ENIP status not OK: {h['status']:#x}")
sid = h["session"]
print(f"[+] Session handle: 0x{sid:08x}\n")
return sid
def cip_get_attr_single(class_id, instance_id, attr_id):
# CIP service 0x0E, path segments for 8-bit class/instance/attribute
path = bytes([0x20, class_id, 0x24, instance_id, 0x30, attr_id])
cip = bytes([0x0E, len(path)//2]) + path
return cip
def build_sendrrdata(cip: bytes, session: int):
# CPF header: interface(4) timeout(2) item_count(2)
cpf = struct.pack("<IHH", 0, 0, 2)
# Item 0: Null Address (0x0000), length 0
cpf += struct.pack("<HH", 0x0000, 0)
# Item 1: Unconnected Data (0x00B2)
cpf += struct.pack("<HH", 0x00B2, len(cip)) + cip
pkt = enip_hdr(0x6F, len(cpf), session) + cpf
return pkt
def extract_cpf_data_item(enip_pkt: bytes):
# skip ENIP header
if len(enip_pkt) < 24:
return None
cpf = enip_pkt[24:]
if len(cpf) < 8:
return None
item_count = struct.unpack_from("<H", cpf, 6)[0]
off = 8
data_item = None
for _ in range(item_count):
if off + 4 > len(cpf):
break
itype, ilen = struct.unpack_from("<HH", cpf, off)
off += 4
if off + ilen > len(cpf):
break
item = cpf[off:off+ilen]
off += ilen
if itype in (0x00B1, 0x00B2):
data_item = item
return data_item
def parse_cip_reply(data_item: bytes):
# data_item contains the CIP reply
if not data_item or len(data_item) < 4:
return None, None
# CIP reply header: service|0x80, reserved, general_status, addl_status_size_words
gen_status = data_item[2]
addl_words = data_item[3]
data_off = 4 + addl_words*2
return gen_status, data_item[data_off:]
def main():
print(f"[*] Connecting to {IP}:{PORT}")
with socket.socket() as s:
s.settimeout(3)
s.connect((IP, PORT))
sid = register_session(s)
# CHANGE THIS for other reads:
CLASS, INST, ATTR = 2, 1, 1 # Message Router 2/1/1 for flag
cip = cip_get_attr_single(CLASS, INST, ATTR)
print(f"[*] CIP Get_Attribute_Single for {CLASS}/{INST}/{ATTR}")
print("[*] CIP bytes:\n" + hexdump(cip) + "\n")
pkt = build_sendrrdata(cip, sid)
print("[*] -> SendRRData (0x6F)")
print("[*] ENIP packet:\n" + hexdump(pkt))
s.sendall(pkt)
r = s.recv(8192)
print("[*] <- SendRRData reply")
print("[*] Reply:\n" + hexdump(r) + "\n")
h = parse_enip_header(r)
if not h:
raise RuntimeError("Bad ENIP reply")
print(f"[*] ENIP status: {h['status']:#x}, session: 0x{h['session']:08x}, length: {h['length']}")
data_item = extract_cpf_data_item(r)
if not data_item:
raise RuntimeError("No CPF data item found")
print("[*] CPF data item (CIP reply):\n" + hexdump(data_item) + "\n")
st, cip_data = parse_cip_reply(data_item)
print(f"[*] CIP general status: {st:#x}")
print("[*] CIP data bytes:\n" + hexdump(cip_data) + "\n")
# For this HTB, flag is UTF-16LE
decoded = cip_data.decode("utf-16le", errors="ignore")
print("[+] Decoded output:")
print(decoded)
if __name__ == "__main__":
main()
Result :

Execution Walkthrough
Session Registration
[*] -> RegisterSession (0x65) [*] <- RegisterSession reply [+] Session handle: 0xbbfd0b48
Session successfully established.
CIP Read Request (2/1/1)
[*] CIP Get_Attribute_Single for 2/1/1 [*] CIP bytes: 0e 03 20 02 24 01 30 01
This requests:
Attribute 1 of Instance 1 of Class 2 (Message Router)
SendRRData Packet
[*] -> SendRRData (0x6F) [*] <- SendRRData reply [*] ENIP status: 0x0
Request succeeded.
CIP Payload Extraction
[*] CIP data bytes: 48 00 54 00 42 00 7b 00 ...
This byte pattern indicates UTF-16LE encoding.
Decoded Output
HTB{3th3rn3t1p_pwn3d}
Small Note:
There are multiple ways to solve this challenge; one of them is brute-forcing all classes and attributes. However, this is an ICS challenge, not a web challenge.
Doing this in a real industrial environment could damage or disrupt the PLC. Thatβs why understanding the protocol logic and object model is the correct and safer approach, and it makes the challenge much easier to solve.