esc

Ether Tag HTB ICS challenge Write-up

πŸ“… Last Updated: Jan 21, 2026 11:00 | πŸ“„ Size: 11.5 KB | 🎯 Type: Security Research Report | πŸ”— Back to Categories

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 FLAG tag 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)

  1. Before any CIP message can be sent, EtherNet/IP requires a session.
		Client
		  |
		  | RegisterSession (0x65)
		  |
		  | SendRRData (0x6F)
		  |   └─ CIP: Get_Attribute_Single
		  |
		PLC

  1. Building a CIP Read Request This creates a CIP Get_Attribute_Single request:
  • 0x0E β†’ Get_Attribute_Single
  • 0x20 β†’ Class segment
  • 0x24 β†’ Instance segment
  • 0x30 β†’ Attribute segment
  1. 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
  1. 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.