In my previous post, I wrote about how I obtained root on the stock firmware of my switch to try to figure out how the stock firmware was implementing per-port power limits. The next step was to actually perform the introspection into what the stock firmware was doing.

First attempt: attaching a debugger on the system binary

My first attempt was to get GDB working so I could set breakpoints and see exactly what the system binary was doing regarding PoE.

More specifically, I wanted to get gdbserver working on the target and perform debugging remotely. gdbserver has fewer dependencies and should be easier to cross-compile. The frontend of gdb can run on my host computer.

Even with its fewer depedencies, compiling gdbserver wasn’t quite as straightforward as I hoped. The toolchain included in TP-Link’s GPL source code release was quite old, and didn’t support many of the newer compiler features that the newer versions of GDB requires at build-time. To work around this, I compiled the last version of GDB before C++11 support was made mandatory (which turned out to be version 7.12 - about 9 years old at time of writing). I also had to separately compile a copy of gdb that supported MIPS for my host computer, since the precompiled gdb binary that came with TP-Link’s toolchain required a bunch of 32-bit libraries that my version of Debian no longer packages.

After compiling, I scp’d gdbserver onto the switch, and attempted to attach it to the main system binary. Unfortunately, for whatever reason, it seems the stock firmware seems to freeze up if the main system binary gets paused/stopped, even for a short length of time. There just didn’t appear to be enough time to attach the debugger, then immediately continue. Network connectivity gets lost, and the switch becomes no longer responsive.

Bummer…

My guess is that there’s some kind of watchdog mechanism that’s shutting down the system if the main system binary doesn’t seem responsive. No idea if that’s actually the case. In any case, it wouldn’t really help, so I didn’t bother looking any deeper.

Second attempt: writing a program to dump I2C registers

For my second attempt, I took a more black-box approach. Perhaps I could dump out the register state of the controller chip, then compare the values between configuration changes in the Web UI.

Interacting with the I2C controller

Sadly, the stock kernel doesn’t implement proper I2C drivers, so I couldn’t just run something like i2cdump. Some digging in the stock firmware revealed a proprietary kernel module called rtcore.ko was being used to arbitrate access to the switch hardware - including implementing the GPIO bit-bang’d I2C bus on which the PoE controller chip lives. The way that the system binary interacts with this kernel module is through opening a magic character device under /dev/rtcore and making special ioctl calls.

With the help of Ghidra and some header files found in the source code, I was able to construct a function from scratch that does something functionally identical to what the system binary does:

struct ioctl_i2c_read {
        int32_t ret;
        uint32_t drv_type;
        uint32_t dev;
        uint32_t reg;
        uint32_t data;
};

int32_t drv_gpio_i2c_read(uint8_t drv_type, uint32_t dev, uint32_t reg, uint32_t *data) {
        int fd = open("/dev/rtcore", O_RDWR);
        if (fd < 0)
                return -1;

        struct ioctl_i2c_read req = {
                .drv_type = drv_type,
                .dev = dev,
                .reg = reg,
        };
        ioctl(fd, 0xc0045251, &req);
        close(fd);

        *data = req.data;
        return req.ret;
}

The parameter names are the same names used in the header files included with the source code release. It’s not immediately clear what drv_type does, but the system binary always seems to set this to zero. dev is the I2C address of the device. reg is the register number to be read.

(Incidentally, I found the choice of data types odd. I2C addresses are typically 7 bits, and register numbers and values are typically 8 bits, yet all the data types representing them are 32-bit integers).

The userspace code populates the structure containing the parameters for a I2C read request, and makes the magic ioctl call. The ioctl handler executes the request and populates the return code and data from the I2C transaction (or zero if it failed).

Dumping the hardware register state

The address of the PoE controller chip can be determined with the help of the datasheet and careful inspection of the circuit board. The top 3 bits of the address are fixed, and the bottom 4 bits are determined by whether certain pins are pulled high or low.

Now that both the method for interacting with the I2C bus and the address of the PoE controller was known, writing a short C program to exhaustively dump all the registers was relatively trivial.

Using that program, I dumped the hardware register state, and saved the output to a file. Then, I made a change in the Web UI changing a port’s power limits, and dumped the hardware register state again, saving the output to a file.

Interestingly, the diff revealed no differences in the current limit configurations. Regardless of the power figures configured in the Web UI, the hardware per-port current limits remained identical. The only differences appeared to be differences in status register values and the ADC readings of voltages and currents. If any per-port power limiting was being enforced, it didn’t appear to be done in hardware.

Clearly more investigation was warranted.

Third attempt: strace

Since it’s now known that the I2C transactions are faciliated using ioctl syscalls, there wasn’t really a need to attach a full debugger onto the system binary process and risk locking the system up. We can use strace to show all the I2C-related ioctl calls and the contents of any data that is read or written.

Like with gdbserver, I had some trouble cross-compiling some of the more recent versions of strace, so I also needed to find an older version (more specifically, version 6.7 which was about 1.5 years old at time of writing).

Partial success

I invoked strace and attached it to the process of interest with ./strace -f -e trace=ioctl -p 104. Note that it’s important to filter to ioctl syscalls only, since I found tracing everything would occasionally pause the process long enough to lock up the system.

The good news was that this showed a bunch of lines that were of interest:

[pid   612] ioctl(86, _IOC(_IOC_READ|_IOC_WRITE, 0x52, 0x51, 0x4), 0x30724e10) = 0

There were all sorts of ioctls that were related to I2C transactions - both reads and writes to the PoE controller.

The bad news was that strace does not understand the custom ioctl numbers, and thus doesn’t know how to decode the argument. It just prints a raw pointer value, which isn’t helpful at all since we’re interested in the data that the pointer points to. It would be nice if we could actually see exactly what data the syscall was returning with.

Patching strace

I ended up patching my version of strace to print out the struct field values for the ioctls of interest (instead of just a raw pointer value). I was pleasantly surprised to find how little code I had to hack to make this work:

--- strace-6.7/src/ioctl.c	2023-02-26 08:00:00.000000000 +0000
+++ strace-6.7-modified/src/ioctl.c	2025-10-01 15:22:21.861449958 +0000
@@ -320,6 +320,43 @@
 	return rc;
 }
 
+struct rtcore_i2c_op {
+	int32_t ret;
+	uint32_t drv_type;
+	uint32_t dev;
+	uint32_t reg;
+	uint32_t data;
+};
+
+static int
+rtcore_ioctl(struct tcb *tcp, const unsigned int code, const kernel_ulong_t addr)
+{
+	if (entering(tcp)) {
+		return 0;
+	}
+
+	struct rtcore_i2c_op op;
+	switch (_IOC_NR(code)) {
+	case 0x51: // drv_gpio_i2c_read
+	case 0x52: // drv_gpio_i2c_write
+		if (!umove_or_printaddr(tcp, addr, &op)) {
+			tprint_struct_begin();
+			PRINT_FIELD_D(op, ret);
+			tprint_struct_next();
+			PRINT_FIELD_X(op, drv_type);
+			tprint_struct_next();
+			PRINT_FIELD_X(op, dev);
+			tprint_struct_next();
+			PRINT_FIELD_X(op, reg);
+			tprint_struct_next();
+			PRINT_FIELD_X(op, data);
+			tprint_struct_end();
+			return RVAL_IOCTL_DECODED;
+		}
+	}
+	return RVAL_DECODED;
+}
+
 /**
  * Decode arg parameter of the ioctl call.
  *
@@ -385,8 +422,9 @@
 		return mtd_ioctl(tcp, code, arg);
 	case 'O':
 		return ubi_ioctl(tcp, code, arg);
-	case 'R':
-		return random_ioctl(tcp, code, arg);
+	case 'R': /* 0x52 */
+		//return random_ioctl(tcp, code, arg);
+		return rtcore_ioctl(tcp, code, arg);
 	case 'T':
 		return term_ioctl(tcp, code, arg);
 	case 'V':

Points of interest:

  • The ioctl type number was already being used by a different ioctl, so decoding is technically ambiguous.
    • Not really a problem in our particular case since I don’t believe the other ioctl ever gets used by the system binary.
  • We only decode the struct arg upon exiting the syscall.
    • The system binary doesn’t initialize the ret or data fields on reads, since it’s expecting the ioctl call to populate them.
    • If we decode the struct arg prior to entering the syscall, those fields contain garbage, making the output more difficult to reason about.
  • It turns out that the ioctl to write to the I2C device uses an identical structure, so also supporting decodes of write ioctls was trivial.

Success

With a patched strace, we get far more meaningful output:

[pid   612] ioctl(87, _IOC(_IOC_READ|_IOC_WRITE, 0x52, 0x51, 0x4){ret=0, drv_type=0, dev=0x21, reg=0x2c, data=0}) = 0

Nice.

The (not so) exciting conclusion

The strace output turned out to be consistent with our hypothesis that per-port power limits weren’t being enforced in hardware.

There was a handful of status registers that were being polled repeatedly, almost as quickly as if it were done in a tight loop. Once a PoE device was connected, per-port voltages and currents values also started being polled repeatedly.

From this, I concluded that per-port power limits were enforced entirely in software (or not enforced at all). My guess is that it’s repeatedly sampling the instantaneous voltage and current to calculate the instantaneous power figure, and manually shutting down the port if it goes over the configured limits.

In any case, this is enough information for me to write a safe Linux driver, so I will declare success for now.