Reversing the ICQ 2000b Protocol: A Deep Dive

Quick Background

ICQ was one of the first big instant messaging programs on the internet. It came out in late 1996, built by a small company called Mirabilis. At the time, most people were still using dial-up and email was the main way to talk online. ICQ felt new because it let you send quick messages, see when friends were online, and chat in real time. It also gave every user a unique number called a UIN, which became a kind of digital identity.

The early versions were simple, but they spread fast. By the late 90s ICQ had millions of users around the world. Features like file transfer, chat rooms, user directories, and even early voice messaging helped shape what we now see in modern messaging apps. AOL bought ICQ in 1998 during the dot-com boom, and later the service changed hands again, but the core idea stayed the same.

For a lot of people who grew up in that era, ICQ was their first taste of true online social life. The "uh-oh" message sound, long UINs, and the quirky green flower icon became symbols of that early internet culture. Even today, ICQ still runs, but its legacy mostly lives on in the apps that followed—MSN, AIM, Yahoo Messenger, and the entire wave of modern chat platforms.

Introduction

This document chronicles my journey reverse-engineering the ICQ 2000b client protocol to build a compatible open-source server implementation. What started as a client stuck at "registering user" turned into a fascinating exploration of early 2000s instant messaging protocols, binary data formats, and the detective work required to resurrect vintage software.

Background: The OSCAR Protocol

ICQ 2000b uses AOL's OSCAR (Open System for CommunicAtion in Realtime) protocol, which consists of two primary layers:

  • FLAP (FLow APplet): The transport layer providing channel-based framing
  • SNAC (Simple Network Atomic Communication): The application layer with family/subtype message routing

The ICQ-specific extensions live within SNAC family 0x0015 (ICQ Extensions), which encapsulates ICQ's proprietary meta-data protocol used for user information, offline messages, and other ICQ-specific features.

The Problem: Stuck at "Registering User"

My server successfully handled:

  • Initial authentication (MD5-based password verification)
  • BOS (Basic OSCAR Service) connection
  • Client capability negotiation
  • Client going "online" on the server side

Yet the ICQ 2000b client remained stuck showing a spinning "registering user" indicator. The client was silently waiting for something I wasn't providing.

Investigation Phase 1: The Retry Loop

The Symptom

Early packet captures revealed the client sending the same request repeatedly in a tight loop:

Client -> Server: SNAC(0x0015, 0x0002) - ICQ Extensions Request
Client -> Server: SNAC(0x0015, 0x0002) - ICQ Extensions Request
Client -> Server: SNAC(0x0015, 0x0002) - ICQ Extensions Request
...repeating indefinitely

Initial Analysis

Examining the ICQ Extensions request with hex dump:

Offset  Hex Data                          Interpretation
------  --------------------------------  ----------------------------------
0x00    00 01                             TLV Type: 0x0001 (ICQ meta data)
0x02    00 1A                             TLV Length: 26 bytes
0x04    18 00                             Data Length: 24 bytes (LE)
0x06    A3 86 01 00                       UIN: 100003 (LE)
0x0A    D0 07                             Request Type: 0x07D0 (LE)
0x0C    02 00                             Sequence Number: 2 (LE)
0x0E    BA 04                             Sub-command: 0x04BA (LE)

Key Discovery: The byte at offset 0x0E contained 0x04BA - "Request Short User Info"

The First Bug: Wrong Reply Type

My initial code was responding with reply type 0x00A0 (SetPermissions), which had nothing to do with user information. The client was rejecting this nonsensical response and retrying.

Fix: Changed reply type to 0x0104 (ShortInfo) to match the 0x04BA request.

Result: The retry loop stopped! But the client UI remained stuck at "registering user."

Investigation Phase 2: The Mystery of Two Request Types

The Confusion

After stopping the retry loop, we observed the client sending a DIFFERENT request:

0x0A    D0 07                             Request Type: 0x07D0 (LE)
0x0E    D0 04                             Sub-command: 0x04D0 (LE)

Wait - there are TWO different codes here: 0x07D0 and 0x04D0?

The Revelation: Nested Request Structure

Through careful analysis of packet captures from a working server, I discovered ICQ's meta protocol uses a nested two-level structure:

Outer Level (SNAC layer):

  • 0x07D0 = ICQDBQueryMetaReq (generic "this is a meta request" indicator)
  • 0x07DA = ICQDBQueryMetaReply (generic "this is a meta reply" indicator)

Inner Level (Meta sub-commands):

  • 0x04BA = Request Short User Info
  • 0x04D0 = Request Full User Info 2
  • 0x000E = Request Offline Messages
  • 0x00C8 = BasicInfo reply
  • 0x0104 = ShortInfo reply
  • 0x00DC = MoreInfo reply
  • 0x00EB = ExtEmailInfo reply

This is analogous to HTTP methods (GET/POST) vs URL paths - the outer type says "this is a meta request," while the inner sub-command specifies WHAT kind of request.

I discovered this by carefully comparing packet captures - the working server's responses consistently used 0x07DA at offset 0x0A, while the sub-reply type appeared at offset 0x0E. By mapping the request patterns (0x07D0 at offset 0x0A, sub-command at 0x0E), the symmetry became clear.

The Second Bug: Reading the Wrong Offset

My code was reading the request type at bytes [10-11]:

uint16_t meta_cmd = data[10] | (data[11] << 8);  // WRONG!

This gave me 0x07D0 (the outer request type), not the actual command I needed.

The Fix: Read bytes [14-15] instead:

uint16_t meta_cmd = data[14] | (data[15] << 8);  // Correct!

Now I could properly distinguish between:

  • 0x04BA (short info request) → reply with 0x0104
  • 0x04D0 (full info request) → reply with 0x00C8
  • 0x000E (offline messages) → reply with 0x0042

Investigation Phase 3: Network Configuration Issues

The BOS Redirect Problem

After authentication, the server sends a BOS redirect telling the client where to connect for the main session:

Server -> Client: "Connect to 127.0.0.1:5191 for BOS service"

The client promptly failed with "registration failed."

The Issue: The client was connecting from 172.27.176.1 (WSL2 network) and couldn't reach 127.0.0.1 (localhost on the server).

Fix #1: Changed BOS address to the WSL2 interface IP: 172.27.185.187:5191

Port Number Mismatch

The client connected briefly but still failed.

The Issue: ICQ 2000b expects the BOS service on the SAME port as the auth service (5190), not a different port.

Fix #2: Changed BOS address to: 172.27.185.187:5190

Now the client successfully completed the BOS connection and went online!

Investigation Phase 4: The Reply Structure Mystery

Still Stuck Despite Correct Protocol

At this point:

  • Client connects to auth server (port 5190)
  • Client authenticates successfully
  • Client connects to BOS server (same IP:port)
  • Client goes ONLINE on server side
  • Client sends ICQ meta request 0x04D0 (Full Info)
  • Server correctly identifies request type
  • Client STILL shows "spinning registering user"

The Third Bug: Using Sub-Type as Outer Type

My code was sending:

Response structure:
  UIN: 4 bytes
  Reply Type: 0x00C8 (BasicInfo) ← WRONG!
  Sequence: 2 bytes
  ... user data ...

But examining the working server's pcap revealed:

Response structure:
  UIN: 4 bytes
  Reply Type: 0x07DA (ICQDBQueryMetaReply) ← Outer type
  Sequence: 2 bytes
  Sub-Reply Type: 0x00C8 (BasicInfo) ← Inner type
  ... user data ...

The Pattern: Just like requests have outer type (0x07D0) + inner command (0x04D0), replies have outer type (0x07DA) + inner sub-reply (0x00C8).

The Fix:

// Meta reply type (always 0x07DA for ICQ meta replies)
response_data.push_back(0xDA);
response_data.push_back(0x07);

// Sequence number
response_data.push_back(seq_num & 0xFF);
response_data.push_back((seq_num >> 8) & 0xFF);

// Sub-reply type (what KIND of meta reply this is)
response_data.push_back(sub_reply_type & 0xFF);
response_data.push_back((sub_reply_type >> 8) & 0xFF);

The server now sends correctly structured replies, but the client STILL won't exit the "registering user" screen!

Investigation Phase 5: The Content Format

Analyzing the Working Server

I obtained a packet capture from a working ICQ server implementation and began analyzing the exact byte sequences it was sending.

Examining the working server's BasicInfo reply revealed a surprising discovery:

Working server's BasicInfo (0x00C8) reply:
Offset  Hex Data          Interpretation
------  ----------------  ----------------------------------
0x00    A3 86 01 00       UIN: 100003 (little-endian)
0x04    DA 07             Reply Type: 0x07DA (ICQDBQueryMetaReply)
0x06    02 00             Sequence: 2
0x08    C8 00             Sub-Reply: 0x00C8 (BasicInfo)
0x0A    0A 00             Success Code: 0x000A (2 BYTES!)
0x0C    00 00 00 00 ...   All zeros (28 bytes)

Total data length: 40 bytes (0x28)

My Incorrect Implementation

I was sending:

// Success result code (1 byte) ← WRONG!
response_data.push_back(0x0A);

// Nickname (length-prefixed string)
std::string nickname = session->uin();
response_data.push_back(static_cast<uint8_t>(nickname.length()));
response_data.insert(response_data.end(), nickname.begin(), nickname.end());
response_data.push_back(0x00);  // Null terminator

// First name, last name, email, city, state, phone, fax...
// (all empty with just length byte 0x00)
response_data.push_back(0x00);
response_data.push_back(0x00);
// ... many more fields ...

// Country code (2 bytes)
response_data.push_back(0x00); response_data.push_back(0x00);
// GMT offset, flags, etc.

The Fourth Bug: Wrong Success Code Size and Overcomplicated Format

Problems:

  1. Success code was 1 byte, should be 2 bytes (little-endian 0x000A)
  2. I was sending user info fields (nickname, email, etc.) when the working server just sends zeros
  3. Byte alignment was off due to the 1-byte vs 2-byte success code

The Fix:

// Success result code (2 bytes, little-endian)
response_data.push_back(0x0A);  // Result: success (0x000A)
response_data.push_back(0x00);

// Rest is zeros (working server sends all zeros after success code)
// Total response should be 40 bytes (0x28) according to working server
// We've used: 4 (UIN) + 2 (reply type) + 2 (seq) + 2 (sub-reply) + 2 (success) = 12 bytes
// Need 28 more bytes of zeros
for (int i = 0; i < 28; i++) {
    response_data.push_back(0x00);
}

Much simpler! The BasicInfo reply is just:

  • 2-byte success code (0x000A)
  • 28 bytes of zeros
  • Total: 30 bytes of payload (plus the 10-byte header)

Key Technical Insights

1. Little-Endian Everything

ICQ uses little-endian byte order for ALL multi-byte integers in the meta data:

0x04D0 is stored as: D0 04
0x07DA is stored as: DA 07
UIN 100003 (0x000186A3) is stored as: A3 86 01 00

This is consistent with ICQ's PC/x86 origins.

2. Two-Level Command Structure

The meta protocol uses a two-level hierarchy:

SNAC Layer:
  Family: 0x0015 (ICQ Extensions)
  Subtype: 0x0002 (Client Meta Request) or 0x0003 (Server Meta Reply)

ICQ Meta Layer:
  Request Type: 0x07D0 (all client meta requests use this)
  Reply Type: 0x07DA (all server meta replies use this)

  Sub-Commands (the ACTUAL command):
    0x04BA = Request Short Info
    0x04D0 = Request Full Info 2
    0x000E = Request Offline Messages

  Sub-Replies (the ACTUAL reply type):
    0x00C8 = BasicInfo
    0x0104 = ShortInfo
    0x0042 = End of Offline Messages

3. TLV Wrapping

ICQ meta data is wrapped in a TLV (Type-Length-Value) structure:

TLV Type: 0x0001 (2 bytes)
TLV Length: N bytes (2 bytes)
Data Length: N-2 bytes (2 bytes, little-endian)
... actual payload ...

The "Data Length" field is 2 bytes less than "TLV Length" because it doesn't count itself.

4. Minimal Data Philosophy

The working server sends minimal data in the BasicInfo reply - just a success code and zeros. This was counterintuitive, as I initially expected to populate user profile fields (nickname, email, etc.).

This suggests:

  • The BasicInfo reply might be just an acknowledgment
  • Real user data may come in subsequent replies (MoreInfo 0x00DC, ExtEmailInfo 0x00EB, etc.)
  • The client may only care about receiving SOME reply to exit the "registering user" state

Debugging Methodology

Tools Used

  1. Wireshark/tshark: Packet capture and protocol analysis

    tshark -r capture.pcap -Y "tcp.port == 5190" -x
  2. xxd: Hex dump analysis for byte-level inspection

    xxd -s offset -l length file.pcap
  3. Server Logs: Strategic debug output showing exact byte values

    std::cout << "meta_cmd = 0x" << std::hex << meta_cmd << std::dec << std::endl;
  4. Working Reference: Comparing against a known-working server's:

    • Packet captures
    • Database schema
    • Source code

The Process

  1. Capture Everything: Run client with packet capture active
  2. Identify Symptoms: What's different between working and broken?
  3. Hypothesis: Form a theory about what's wrong
  4. Targeted Logging: Add debug output to test hypothesis
  5. Compare Bytes: Hex-dump comparison between working and broken
  6. Fix and Test: Make minimal changes, rebuild, test
  7. Iterate: Repeat until behavior matches working server

Common Pitfalls

  • Endianness: Always remember little-endian for ICQ meta data
  • Offset Errors: Count bytes carefully; off-by-one errors are deadly
  • Assumptions: Don't assume field sizes (1 byte vs 2 bytes for success code)
  • Over-engineering: Sometimes simpler is correct (zeros instead of user data)

Current Status

My server now:

  • Handles authentication correctly
  • Manages BOS connections on the correct IP:port
  • Properly parses nested ICQ meta request structure
  • Sends correctly formatted ICQ meta replies with proper outer/inner types
  • Uses correct 2-byte success code
  • Sends minimal BasicInfo reply matching working server format

Next Steps:

  • Test if the simplified BasicInfo reply (success code + zeros) resolves the client hang
  • Potentially implement additional reply types (MoreInfo 0x00DC, ExtEmailInfo 0x00EB)
  • Investigate if the client expects MULTIPLE replies for a FullInfo2 request
  • Monitor for any additional requests the client sends after registration completes

Lessons Learned

1. Trust But Verify

Even when you have working packet captures as a reference, don't assume your understanding is correct. I assumed the success code was 1 byte based on my mental model, but the working server clearly showed it was 2 bytes.

2. Byte-Level Precision Matters

In binary protocols, every byte counts. A single-byte error in offset calculation (reading bytes 10-11 instead of 14-15) completely broke my command detection logic.

3. Simplicity Over Complexity

I tried to send comprehensive user data (nickname, email, city, etc.), but the working server just sends zeros. Sometimes the minimal implementation is the correct one.

4. Layer Your Debugging

Work from the outside in:

  • Network layer: Can they connect?
  • Transport layer: Is TCP working?
  • FLAP layer: Are frames properly formatted?
  • SNAC layer: Are family/subtypes correct?
  • ICQ meta layer: Are request/reply types right?
  • Data layer: Is the payload format correct?

5. Documentation Is Scarce for Legacy Protocols

Modern protocols have RFCs, specs, and extensive documentation. ICQ 2000b has... almost nothing. Reverse engineering requires:

  • Packet captures from working implementations
  • Careful byte-by-byte analysis
  • Trial and error
  • Patience

Technical Details Worth Noting

The ICQ Meta Request Format

Offset  Size  Field                    Byte Order
------  ----  -----------------------  -----------
0x00    2     TLV Type (0x0001)        Big-endian
0x02    2     TLV Length               Big-endian
0x04    2     Data Length              Little-endian
0x06    4     UIN                      Little-endian
0x0A    2     Request Type (0x07D0)    Little-endian
0x0C    2     Sequence Number          Little-endian
0x0E    2     Sub-Command              Little-endian

The ICQ Meta Reply Format

Offset  Size  Field                    Byte Order
------  ----  -----------------------  -----------
0x00    2     TLV Type (0x0001)        Big-endian
0x02    2     TLV Length               Big-endian
0x04    2     Data Length              Little-endian
0x06    4     UIN                      Little-endian
0x0A    2     Reply Type (0x07DA)      Little-endian
0x0C    2     Sequence Number          Little-endian
0x0E    2     Sub-Reply Type           Little-endian
0x10    2     Success Code (0x000A)    Little-endian
0x12    N     Payload Data             (varies)

Mixed Endianness!

One interesting quirk: The TLV Type and TLV Length fields use big-endian (standard network byte order), while everything in the ICQ meta payload uses little-endian. This mixed endianness reflects ICQ's evolution - the outer OSCAR/SNAC layers use network byte order, while the inner ICQ-specific data uses x86 native byte order.

Conclusion

Reverse engineering the ICQ protocol has been an exercise in patience, precision, and detective work. Each bug I fixed revealed another layer of complexity, from simple reply type mismatches to subtle byte-alignment issues.

The journey demonstrates that even "simple" protocols from the early 2000s contain surprising depth. The nested request/reply structure, mixed endianness, and minimal-data philosophy all reflect design decisions made 25+ years ago that still impact implementation today.

Most importantly, this experience highlights the value of packet captures from working implementations. Without the reference captures to compare against, identifying issues like the 2-byte success code or the nested request structure would have taken exponentially longer.

The ICQ 2000b client may be vintage software, but the protocol reverse engineering skills required to support it remain relevant for anyone working with proprietary protocols, legacy systems, or undocumented APIs.

Appendix: Useful Code Snippets

Reading Little-Endian 16-bit Value

uint16_t read_le16(const std::vector<uint8_t>& data, size_t offset) {
    return data[offset] | (data[offset + 1] << 8);
}

Writing Little-Endian 16-bit Value

void write_le16(std::vector<uint8_t>& data, uint16_t value) {
    data.push_back(value & 0xFF);
    data.push_back((value >> 8) & 0xFF);
}

Hex Dump Debug Output

void hex_dump(const std::vector<uint8_t>& data, const std::string& label) {
    std::cout << label << " (" << data.size() << " bytes): ";
    for (size_t i = 0; i < data.size(); i++) {
        printf("%02x ", data[i]);
    }
    std::cout << std::endl;
}

TLV Structure Helper

void write_tlv(std::vector<uint8_t>& output, uint16_t type,
               const std::vector<uint8_t>& data) {
    // TLV Type (big-endian)
    output.push_back((type >> 8) & 0xFF);
    output.push_back(type & 0xFF);

    // TLV Length (big-endian)
    uint16_t length = data.size();
    output.push_back((length >> 8) & 0xFF);
    output.push_back(length & 0xFF);

    // TLV Data
    output.insert(output.end(), data.begin(), data.end());
}

This document is a living record of our protocol reverse engineering journey. As I discover more about ICQ's inner workings, I'll continue updating this resource for future implementers.