<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Dave's Blog and Notes]]></title><description><![CDATA[Dave's Blog and Notes]]></description><link>https://davelukas.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 25 Oct 2026 01:50:16 GMT</lastBuildDate><atom:link href="https://davelukas.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Talking to a cheap switch in its own language]]></title><description><![CDATA[How the TP-Link Easy Smart UDP admin protocol works: a decompiled Java utility, a firmware diff, RC4 and a 64-bit RSA handshake, verified on TL-SG108E]]></description><link>https://davelukas.hashnode.dev/talking-to-a-cheap-switch-in-its-own-language</link><guid isPermaLink="true">https://davelukas.hashnode.dev/talking-to-a-cheap-switch-in-its-own-language</guid><category><![CDATA[networking]]></category><category><![CDATA[Terraform]]></category><category><![CDATA[SWITCH]]></category><category><![CDATA[tp link]]></category><category><![CDATA[automation]]></category><category><![CDATA[#IaC]]></category><category><![CDATA[TL-SG108E]]></category><category><![CDATA[tp-link-easysmart]]></category><category><![CDATA[reverse engineering]]></category><category><![CDATA[Homelab]]></category><category><![CDATA[Security]]></category><category><![CDATA[Cryptography]]></category><dc:creator><![CDATA[David Lukac]]></dc:creator><pubDate>Sun, 18 Oct 2026 03:15:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdb60fdb00f9e17b8299bb/85ee6e3c-e32c-4365-b583-515ba8eb5045.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I own two TP-Link TL-SG108E switches. Eight ports, no fan, the size of a paperback. They do VLANs, link aggregation and port mirroring, and the only supported way to configure any of it is a web page that looks like 2012.</p>
<p>I wanted to manage them from code. My first plan was to script that web UI, where the settings sit in JavaScript objects inside HTML pages and every change is a GET request to a <code>.cgi</code> page, which works but is chatty and brittle, and any firmware update can move things around. So I looked for another way in. Terraform comes in at the end.</p>
<h2>Port scan and discovery</h2>
<p>The first question was what the switch listens on. A connect scan of the usual management ports (21, 22, 23, 69, 80, 161, 443, 8080, 8443 and a few more) found only TCP 80 open. <code>snmpget</code> timed out. No SSH, no telnet, no HTTPS.</p>
<p>TP-Link also ships a desktop tool, the Easy Smart Configuration Utility, that finds switches on the network and configures them without a browser. So something else was talking. Earlier open-source clients, mainly <a href="https://github.com/rgl/smrt">smrt</a> and its forks, had already mapped the outline: UDP, port 29808 on the switch, replies to port 29809 on your machine, and only inside one layer-2 segment, meaning no router in between. I sent a discovery broadcast and both of my switches answered with model, MAC address, firmware version and hostname. No password needed. My project calls this protocol ESCP.</p>
<p>The appeal over the web UI is practical. The payload is typed binary instead of HTML and JavaScript, one GET can ask for several settings at once, and discovery needs no credentials and does not depend on the switch's IP configuration, so it still finds a switch whose address is wrong. A UDP switch is also far easier to fake in tests than a stateful web server.</p>
<h2>Unpacking the vendor tool</h2>
<p>The utility is a Java program wrapped in an InstallShield <code>.exe</code>, which macOS cannot open. I carved the embedded CAB out of the installer and unpacked it, then found that the application executable has a jar appended to it, which you locate through the zip end record:</p>
<pre><code class="language-shell"># schematic: the carving steps were scripted
unshield -d out x data1.cab    # exits 1 over one unrelated file; the application still comes out
jadx -q -d decompiled app.jar  # decompile the jar carved from the application exe
</code></pre>
<p>The class names are obfuscated, but numeric constants survive. The decompiled sources contain 148 distinct ones, and most of the protocol's setting identifiers sit together in one obfuscated class, with a few more in two others.</p>
<h2>What a packet looks like</h2>
<p>A 32-byte header, then a list of TLVs: type, length, value.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6acdb60fdb00f9e17b8299bb/0d6a6c2d-1045-42cc-ab4c-f68fb5304833.png" alt="Diagram of a request: a 32-byte header with eleven fields, then TLVs for LED, port settings and QoS mode, then the end marker ff ff 00 00." style="display:block;margin:0 auto" />

<p>Python reads the header in one line:</p>
<pre><code class="language-python">struct.unpack("!bb6s6shihhhhi", packet[:32])
# version, op, switch MAC, host MAC, sequence, error,
# total length, fragment, flag, token id, checksum
</code></pre>
<p>The <code>!</code> means network byte order with no padding. The op field says what kind of packet it is: discovery, a read (GET), a write or login (both share one op value), or a reply to one of those. A reply carries the sequence number of the request it answers, so the client can ignore stray packets. Strings end in a NUL byte, and the payload ends with <code>ff ff 00 00</code>.</p>
<p>The settings are numbers, all decimal here. LED on or off is type 12 with one byte. Port settings are 4096, QoS mode is 12288, IGMP snooping is 4352. There is no names table on the wire. I got the numbers from the vendor's code and from earlier clients, and checked each one on the real switch.</p>
<h2>Two RC4 variants</h2>
<p>Discovery and the key-exchange packet use RC4, an old stream cipher that is still common in embedded devices, with a static 256-byte key. The vendor stores that key encrypted inside the jar with TEA, a small block cipher, and ships the decryption routine in the same file. I decrypted it and compared the result with the key smrt had hard-coded, and they matched, which is how I knew the derivation was right. The key ships in every copy of the software, and the cipher is re-initialised for every packet, so there is no stream to keep in sync.</p>
<p>Once the key exchange succeeds, traffic switches to a per-session RC4, keyed with a random eight-character string. The state array in this one has eight entries instead of 256, the same algorithm at toy size. I don't know why. The switch expects exactly this, so the client produces it.</p>
<h2>What changed in the 2023 firmware</h2>
<p>Earlier clients logged in by asking for a token id and then sending the username and password. The release notes for firmware 20230218 say the management protocol was "upgraded to enhance security", so that flow almost certainly would not work on my switches. I did not test it.</p>
<p>Two firmware images were available, 20220930 and 20230218, so I could compare them without any disassembly: extract the printable strings from each and diff the lists. Among the new strings, two mattered. One was <code>RSA_private_decrypt</code>, with its error and success messages. The other was a cookie session for the web UI. That suggests the switch now holds an RSA private key, and expects the client to send it something encrypted. Whether the key is per device or shared by every unit, I have not checked.</p>
<p>The jar has a class that does exactly this. The new handshake is two round trips:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6acdb60fdb00f9e17b8299bb/c3fca4b5-c0fe-4fd5-9dbb-371e01cbc0b8.png" alt="Sequence diagram: the client sends an RSA-encrypted session key, the switch answers with code 4098, the client sends username and password, the switch answers with code 0." style="display:block;margin:0 auto" />

<ol>
<li><p>The client generates the random session key and sends it in a SET packet with TLV 528. The key is encrypted with textbook RSA, which means the raw maths with no padding: exponent 65537, the number stored as little-endian words. The packet itself goes out under the static RC4.</p>
</li>
<li><p>The switch replies with error code 4098, which despite the name means "session key accepted". The meaning comes from the vendor code that handles the reply.</p>
</li>
<li><p>The client sends the username and password as TLVs 512 and 514, now under the session RC4.</p>
</li>
<li><p>The switch replies with code 0, and you are in.</p>
</li>
</ol>
<p>The RSA operand is the one little-endian thing on the wire; everything else is big-endian.</p>
<p>The RSA modulus for the TL-SG108E v6 is only 64 bits long. The vendor utility carries a 1024-bit one for other models and uses the short one for mine. 64 bits offers no real protection.</p>
<p>There is no session token. Every write carries the username and password again, while ordinary reads need no credentials, only the key exchange first (I did not test reads without it). The config backup read below is the exception, since it is an authenticated SET.</p>
<h2>Verifying on the real switch</h2>
<p>A decompiler can mislead you, and the only switch I could log into is on my live network. It has no console port, and the recovery from a bad write is a physical factory reset. So I set rules before sending anything: reads first, cross-checked against the web UI where I could; the LED as the first write, since it is harmless and trivially reversible; and a config backup before any write, a continuous ping running in another window, and experiments on unused ports 6 and 7 only.</p>
<p>The LED toggle worked and the web UI agreed, but the config backup told me more about the protocol itself: an authenticated SET with an empty TLV 768 makes the switch return its whole configuration file, and because that does not fit in one UDP packet the reply comes in fragments, with the fragment field in the header set to 1 on every packet but the last and the client stitching the pieces together. The result was 2258 bytes starting with the magic bytes <code>23 89 23 89</code>, byte-identical to the file the web UI downloads.</p>
<p>Some behaviour only shows up on the real device:</p>
<table>
<thead>
<tr>
<th>What I did</th>
<th>What the switch did</th>
</tr>
</thead>
<tbody><tr>
<td>Asked for IGMP settings 4352, 4354 and 4353 in one GET</td>
<td>Rejected with error code 1. 4353 has to be requested separately</td>
</tr>
<tr>
<td>Asked for a TLV it doesn't know</td>
<td>Usually silence, sometimes an error code</td>
</tr>
<tr>
<td>Wrote QoS priorities while not in port-based mode</td>
<td>Accepted them, then ignored them</td>
</tr>
<tr>
<td>Added ports to a port-based VLAN group</td>
<td>Took them out of their old group</td>
</tr>
</tbody></table>
<h2>Security</h2>
<p>There is no integrity check, every installation shares the same cipher key, the RSA key is 64 bits, and every write carries the password. Anyone who can capture traffic on that segment, including the key exchange, can recover the session key and the credentials, and nothing in the protocol authenticates the switch's replies to the client. TP-Link's own utility does the same, so it is the protocol rather than my client. Put switch management on its own VLAN and keep it away from networks you don't trust.</p>
<h2>Status</h2>
<p>All authenticated testing was on one device, a TL-SG108E hardware v6 running firmware 1.0.0 Build 20230218. Discovery also answered on a second switch with newer firmware, which I could not log into. If you have another Easy Smart model or a different firmware, I would like to know whether the same handshake works for you.</p>
<p>I turned this into a Terraform provider: <a href="https://github.com/davidlukac/terraform-provider-tplinkeasysmart">github.com/davidlukac/terraform-provider-tplinkeasysmart</a>, also on the <a href="https://registry.terraform.io/providers/davidlukac/tplinkeasysmart">Terraform Registry</a>. The protocol notes live in the repo as <a href="https://github.com/davidlukac/terraform-provider-tplinkeasysmart/blob/main/internal/escp/PROTOCOL.md"><code>PROTOCOL.md</code></a>, with the TLV tables, in case you want to write a client in another language.</p>
<p>I did this with Claude Code, an AI coding assistant, which read the decompiled code, wrote the client and its tests, and ran the experiments under the safety rules I set.</p>
]]></content:encoded></item></channel></rss>