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:
local_address/rem_address— IP:port in little-endian hexst— connection state (0A = LISTEN, 01 = ESTABLISHED, etc.)inode— the kernel socket inode number
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.