HOVEL // chapter 24
Chapter 24 Part 04 / Module Development

Case Study: MS17-010 (MS17-010)

The mock modules elsewhere in these guides show the shape of the SDK without touching a real target. This page documents a pair of real modules — ms17-010-survey and ms17-010-exploit — that fingerprint and exploit the MS17-010 SMBv1 vulnerability (CVE-2017-0143) in the Windows kernel driver srv.sys. It is the reference example for a full, network-facing capability in Hovel: a survey that produces facts, an exploit that consumes them, a real memory-corruption primitive, typed capabilities for later payload steps, and the safety machinery that gates a dangerous action.

Both modules live under modules/examples/python/ms17_010_survey/ and modules/examples/python/ms17_010_exploit/. They are pure-Python and carry no third-party dependencies — the SMB and DCE/RPC wire formats are assembled by hand with struct — so they run straight from source through the daemon's interpreter, exactly like the mock examples.

Scope and safety. The implementation targets Windows XP SP3 (x86), srv.sys build 5.1.2600.5512 — an operating system that has been end-of-life since 2014. Every numeric constant below is specific to that build. The exploit corrupts remote kernel memory and can crash the target; it is intended only for an authorized lab host you control. The module enforces this with the controls described in Integration with Hovel.

What the example demonstrates

  • A survey that feeds an exploit. ms17-010-survey reports whether a host is vulnerable and which named pipe to use; ms17-010-exploit takes those facts as configuration.
  • A genuine kernel read/write primitive built entirely from legitimate-looking SMB requests.
  • Execution without kernel shellcode. The primitive is used to elevate the live SMB session to SYSTEM, after which ordinary administrative operations can create accounts, upload files, manage services, or run a command when those sub-capabilities are implemented.
  • The dangerous-module path: the dangerous tag, an explicit operator confirmation requirement, an audit record, and a clean restore step.

Background: SMB Transactions in one minute

SMBv1 lets a client send a request that is too large for one packet by splitting it into a primary request followed by secondary writes. To track this, the server allocates a transaction object in the kernel's paged pool with a data buffer (here called InData) sized to the promised total. Each follow-up packet carries three bookkeeping values:

  • TotalDataCount — how big the whole payload will be, which is how big InData was allocated.
  • DataCount — how many bytes this packet carries.
  • DataDisplacement — where in the buffer to place them: the server copies to InData + DataDisplacement.

Transactions are reached over a named pipe opened on the IPC$ share. On a vulnerable, default Windows XP install that pipe can be opened with an anonymous (NULL) session — no credentials — which is what makes the whole chain remote and unauthenticated. A short, fixed set of pipes is usable this way: spoolss, browser, lsarpc.

The survey module

ms17-010-survey is a survey module: it gathers facts and changes nothing. It performs a real SMBv1 handshake — negotiate, anonymous session setup, tree connect to IPC$ — then probes the three candidate pipes and issues the standard MS17-010 detection request (a PeekNamedPipe transaction against an invalid file id). A vulnerable srv.sys answers that request with NT status STATUS_INSUFF_SERVER_RESOURCES (0xC0000205); a patched one does not. The verdict and the reachable pipes are returned as facts:

{
  "host": "10.0.0.42",
  "port": 445,
  "smb_reachable": true,
  "null_session": true,
  "verdict": "vulnerable",
  "detection_status": "0xc0000205",
  "reachable_pipes": ["spoolss", "browser", "lsarpc"],
  "recommended_pipe": "spoolss",
  "suggested_target_profile": "XP_SP2SP3_X86"
}

A vulnerable result is also surfaced as a critical Finding and a JSON Artifact. Because it is reconnaissance only, the survey carries no dangerous tag and no confirmation gate. Its recommended_pipe and suggested_target_profile are the inputs the exploit expects.

The exploitation primitive

The exploit builds an arbitrary kernel read/write from four observations about how srv.sys handles transactions. Each step is driven by ordinary SMB packets.

1. The flaw: a shifted buffer, not a missing check

When a secondary write arrives, the server copies DataCount bytes to InData + DataDisplacement, and it does bounds-check the destination against TotalDataCount. The flaw is a type confusion between a raw WriteAndX request and a Transaction: a raw WriteAndX (WriteMode = write-raw-named-pipe) sent to an in-progress transaction's pipe advances that transaction's InData pointer by the number of bytes written, while leaving TotalDataCount unchanged. A following secondary write with a large DataDisplacement still passes the bounds check — which is measured against the unshifted total — but now lands past the real, shortened buffer, into whatever sits next in the pool. The result is a controlled out-of-bounds kernel write.

2. Grooming: arrange two transactions side by side

A wild overflow just crashes the machine. To make it useful, the module first shapes the paged pool so that the buffer it overflows from is immediately followed by a second transaction it also controls. It does this by allocating a run of identically sized, deliberately incomplete transactions; uniform same-size allocations pack back-to-back, so two of them end up adjacent. One of the run reuses the open pipe's file id as its identifier, which fixes it at a predictable position relative to its neighbour.

3. Leak: turn a peek into a kernel address

Kernel addresses are not guessable, so before corrupting anything the module reads pool memory back. By completing a transaction whose counts it has quietly enlarged, it gets the server to return bytes from beyond the transaction's own buffer — the adjacent chunk — which contains a transaction header. From that header it recovers genuine kernel pointers, including the CONNECTION and SESSION objects for the current SMB session and the addresses of the two groomed transactions.

4. Takeover: one overflow becomes read/write anywhere

The module then performs the shifted-buffer write to overwrite the neighbouring transaction's header — specifically the pointer to its data buffer and its count fields. After that, the neighbour is a re-aimable window into kernel memory:

  • Write: point the victim's data pointer at any kernel address and send it a normal secondary write — the server copies your bytes there.
  • Read: point it at any address and ask for its data back — the server returns the bytes at that address.

A pair of helper transactions keeps the victim valid between operations, giving a stable, reusable read/write primitive. The relevant field offsets for this build are kept in one place, offsets.py: the transaction is 0x98 bytes, with InData at +0x44, TotalDataCount at +0x64, and the identifier used for the takeover oracle at +0x7c.

5. From read/write to reusable access

With kernel read/write in hand there are two well-known routes to execution. One overwrites a function pointer in the SMBv1 transaction dispatch table and supplies kernel shellcode. This module takes the other, which needs no shellcode and is markedly more stable: it edits the current SMB session's own security context. It clears the session's "anonymous" flag and rewrites the token's group list to include the SYSTEM and Administrators groups. The very same TCP connection is now an administrative session — which is confirmed simply by tree-connecting to the C$ share, something an anonymous session cannot do.

From an administrative session, remote operations become routine. The current example can open the Service Control Manager over DCE/RPC, create a one-shot service, and start it. The modular chain model generalizes that result into a RemoteExecutionCapability with explicit sub-capabilities such as exec_command, upload_file, and service_control. The MS17-010 module must only report sub-capabilities it actually implements and can keep valid.

The exploit module

ms17-010-exploit is an exploit module. Its class attributes declare the metadata, the dangerous tag, the chain-level confirmation requirement, and the per-target configuration; run performs the chain. The shape will be familiar from the Python guide:

class MS17010ExploitModule(HovelModule):
    name = "ms17-010-exploit"
    module_type = "exploit"
    summary = "MS17-010 SMBv1 srv.sys RCE — runs a command as SYSTEM."
    tags = ("dangerous", "smb", "ms17-010", "exploit", "python")

    global_config = (
        Requirement("operator.confirmed_lab", "bool",
                    description="Operator confirmed this is an authorized lab target."),
    )
    target_config = (
        Requirement("target.host", "host"),
        Requirement("target.port", "port", default="445"),
        Requirement("pipe", "enum", default="spoolss",
                    allowed=["spoolss", "browser", "lsarpc"]),
        Requirement("command", "string", required=False, default="calc.exe"),
        Requirement("cleanup", "bool", required=False, default="true"),
        Requirement("target_profile", "string", required=False, default="XP_SP2SP3_X86"),
    )

    def run(self, ctx: Context) -> Result:
        ...

The package is split so that each concern is testable on its own:

  • offsets.py — the build-specific structure offsets and pool constants.
  • smb.py — the hand-rolled SMBv1 client: session bring-up, the transaction primitives, raw WriteAndX, and a TransactNamedPipe helper for DCE/RPC.
  • analysis.py — pure parsing of the leaked pool chunk and construction of the replacement token group list (no I/O, fully unit-tested).
  • exploit.py — the chain itself: control, leak, the read/write primitive, elevation, command execution, and restore.
  • svcctl.py — the minimal Service Control Manager client used to run the command.
  • errors.py — a staged error enum so a failure reports exactly which phase stopped (pipe, leak, takeover, walk, payload, and so on).
  • module.py — the HovelModule that ties it together and maps the outcome onto a Result.

On success the module returns a critical finding, a JSON artifact recording the recovered CONNECTION/SESSION addresses and the access status, and capability outputs describing what later steps may safely consume. On failure it returns the staged error identifier, so an operator can see whether, say, the groom missed (re-throw) or the host blue-screened (reset the lab).

Integration with Hovel

Module contract and runtime

Both modules are ordinary jsonrpc-stdio modules. They subclass HovelModule, declare survey / exploit as their module_type, and answer the common RPC methods through the SDK's serve loop. In the chain-step model, the MS17-010 exploit also reports live step contracts through step.describe and executes confirmed steps through step.execute. Nothing about the framing changes for a real module versus a mock; see the Wire Protocol reference.

The dangerous-module path

The exploit participates in Hovel's safety model (see Safety and Scope) in three ways:

  • It is tagged dangerous, so a throw will not run it without --allow-dangerous.
  • It declares a chain-level requirement operator.confirmed_lab and refuses to proceed unless it is set — returning a clean failed result, not an exception, when it is absent.
  • On an authorized throw it writes an audit log line recording the confirmed target, command, and profile before any packet is sent.

The runtime fact that it corrupts kernel memory is therefore never reachable by accident: a plan must exist, the operator must confirm the lab, and the dangerous flag must be present.

The survey-to-exploit handoff

The two modules are designed to chain. The survey's recommended_pipe becomes the exploit's pipe input, and its suggested_target_profile becomes target_profile. In a chain file the survey runs first; its facts are available to the exploit's configuration, so an operator does not hand-pick a pipe — the touch chose one that is anonymously reachable.

The MS17-010 to Squatter handoff

The MS17-010 exploit does not own Squatter sessions. In the representative Squatter chain, it produces a connection-bound RemoteExecutionCapability. A separate Windows credential step consumes that capability to create a temporary local SMB credential with neutral random username and high-entropy random password. The Squatter SMB installer consumes the remote execution capability, the SMB credential, and a Squatter payload artifact to stage and start Squatter. It returns an explicit installed-payload descriptor that Hovel validates against the Squatter provider and persists in SQLite. The Squatter connector then uses the SMB credential to connect to \\target\pipe\name and produce the SessionRef.

The currently implemented legacy CLI bridge keeps that ownership boundary while using module-level chain entries: when Squatter is present in an MS17-010 chain, planning derives payload.transport and bind settings for the MS17-010 exploit. At execution time Hovel asks the Squatter provider to generate the payload artifact and passes the resulting bytes to the exploit as payload.bytes_base64; operators do not configure host-local payload paths. If the exploit reports a Squatter installed-payload descriptor, Hovel records it in the payload inventory and immediately asks the Squatter provider to reconnect, appending the resulting SessionRef to the throw result. Without a Squatter bridge step, the MS17-010 exploit remains command execution only and should not be expected to create a session.

ms17-010.exploit
  -> RemoteExecutionCapability(connection_bound)
windows.credential.create_local_admin
  -> CredentialCapability(protocol=smb), CleanupHandle
squatter.generate
  -> PayloadArtifact
squatter.install_smb
  -> PayloadInstance, SmbPipeEndpoint, CleanupHandle, InstalledPayloadDescriptor
squatter.connect_smb
  -> SessionRef

This is intentionally a representative chain, not a bespoke MS17-010 product. The MS17-010 exploit establishes access; credential, install, installed payload tracking, connect, session, and cleanup behavior are owned by their respective modules and provider steps. It may report that it installed Squatter only by returning a typed installed-payload descriptor naming the Squatter provider and payload id; Hovel still validates that descriptor before persistence.

Configuration and registration

Each module ships as a package with a Python launch entry. Operators install release packages or link local package roots while developing:

hovel module install ./ms17-010-survey-1.0.0.tgz
hovel module install ./ms17-010-exploit-1.0.0.tgz
hovel module install --link "$PWD/modules/examples/python/ms17_010_survey"
hovel module install --link "$PWD/modules/examples/python/ms17_010_exploit"

A throw supplies the target configuration and the confirmation:

{
  "target": "10.0.0.42",
  "chainConfig":  { "operator.confirmed_lab": "true" },
  "targetConfig": { "target.host": "10.0.0.42", "pipe": "spoolss",
                    "command": "calc.exe", "cleanup": "true" }
}

Build and test

The modules are part of the standard gate. Each ships unit tests that exercise the pure logic — the pool-chunk parser, the token-group builder, the packet builders, the wire marshalling, the configuration schema, and the confirmation gate — without any network access, so they run in CI offline. They are wired into the Bazel test suite and pass the same task ci gate (lint, docs, build, and tests) as the rest of the repository. The chain itself is exercised against a live lab host, not in CI.

Where to look

  • modules/examples/python/ms17_010_survey/ — the survey module and its SMB touch.
  • modules/examples/python/ms17_010_exploit/ — the exploit module, primitive, and SCM client.
  • Python Modules — the SDK surface these are built on.
  • Safety and Scope — the trust model the dangerous tag plugs into.
  • Operations, Chains, Throws — how the survey and exploit are chained and thrown.