XCloak / blog
← All posts
2026-07-05 · Engineering · 4 min read

How XCloak Maps Linux /proc/net to Process Names

One of the most useful things a security agent can do is answer: "what process owns this network connection?" On Linux, the answer lives in the /proc filesystem — but it takes a few steps to assemble.

The /proc/net Socket Tables

Linux maintains per-protocol socket tables in /proc/net/:

/proc/net/tcp   — IPv4 TCP sockets
/proc/net/tcp6  — IPv6 TCP sockets
/proc/net/udp   — IPv4 UDP sockets
/proc/net/udp6  — IPv6 UDP sockets

Each row looks like:

sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000       0 12345 ...

The critical fields are:

The inode is the bridge to process information.

Step 1 — Parse the Socket Table

func hexToAddr(hex string) string {
    parts := strings.Split(hex, ":")
    // IP is 4 bytes little-endian hex → parse and reverse
    b, _ := strconv.ParseUint(parts[0], 16, 32)
    ip := net.IP{byte(b), byte(b >> 8), byte(b >> 16), byte(b >> 24)}
    port, _ := strconv.ParseUint(parts[1], 16, 16)
    return fmt.Sprintf("%s:%d", ip.String(), port)
}

Step 2 — Build the Inode → PID Map

Every process has a directory /proc/<pid>/fd/ containing symlinks to its open file descriptors. Socket FDs appear as:

/proc/1234/fd/5 → socket:[12345]

We walk every /proc/<pid>/fd/ and extract socket inodes:

func buildInodePIDMap() map[uint64]int {
    inodePID := make(map[uint64]int)
    entries, _ := os.ReadDir("/proc")
    for _, e := range entries {
        pid, err := strconv.Atoi(e.Name())
        if err != nil { continue } // skip non-numeric entries (not a PID)
        fdDir := fmt.Sprintf("/proc/%d/fd", pid)
        fds, _ := os.ReadDir(fdDir)
        for _, fd := range fds {
            link, err := os.Readlink(filepath.Join(fdDir, fd.Name()))
            if err != nil { continue }
            // "socket:[12345]" → extract inode 12345
            if strings.HasPrefix(link, "socket:[") {
                inode, _ := strconv.ParseUint(link[8:len(link)-1], 10, 64)
                inodePID[inode] = pid
            }
        }
    }
    return inodePID
}

Step 3 — Look Up Process Name

func buildPIDNameMap(pids []int) map[int]string {
    m := make(map[int]string)
    for _, pid := range pids {
        comm, _ := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
        m[pid] = strings.TrimSpace(string(comm))
    }
    return m
}

And for the full exe path:

exe, _ := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))

Putting It Together

inodePID    := buildInodePIDMap()
connections := parseProcNetFiles()
for i, conn := range connections {
    if pid, ok := inodePID[conn.Inode]; ok {
        connections[i].PID         = pid
        connections[i].ProcessName = pidNames[pid]
        connections[i].ProcessPath = pidExe[pid]
    }
}

The result: every connection in the XCloak UI shows the process that owns it — with no privileged access required. This is standard /proc reading, available to any non-root process.

Performance note: On a busy server with thousands of connections and hundreds of processes, this scan can take 100–300 ms. The agent runs it every 30 seconds, so a single scan is well within budget. For very high process counts (>10,000), consider caching the inode map between scans.

← Closing the TOCTOU Gap in Redis Rate Limiting Building Enterprise Android MDM Without Google EMM →