A software tarpit is simple and fun. Long story short, it's sort of a reverse denial-of-service attack. It usually works by inserting an intentional, arbitrary delay in responding to malicious clients, thus wasting their time and resources. It's kind of like those YouTubers who purposely joke around with phone scammers as long as possible to waste their time and have fun. I recently learned about endlessh, an SSH tarpit. I decided it would be a fun exercise to use Rust's async-std library to write an SSH tarpit of my own, with my own personal flair. If you want to learn more about endlessh or SSH tarpits I highly recommend reading this blog post by the endlessh author.

Goals

So what does an SSH tarpit need to do? Basically, an SSH tarpit is a TCP listener that very slowly writes a never-ending SSH banner back to the client. This all happens pre-login, so no fancy SSH libraries are needed. Really, the program just needs to write bytes [slowly] back to the incoming connection and never offer up the chance to authenticate (see the blog post above to learn more). So now I'm getting a to-do list together. My program needs to...

  1. Listen for incoming TCP connections on a given port
  2. Upon receiving an incoming connection, write some data to the TCP stream
  3. Wait a little bit (say 1-10 seconds) and then repeat step 2
  4. Upon client disconnect, continue working on other connections and listening for new ones
  5. Handle many of these connections at the same time with few resources

Additionally, to spruce things up and have more fun, I'm adding the following requirements:

That's right. It's probably a waste of resources, but I want to be able to feed the attacker whatever information I want. For example, I want to be able to pipe a Unix Fortune across the network to the attacker very slowly. I want to relish in the knowledge that if the attacker manually debugs the data coming down the pipe, they'll see their fortune.

Implementation

I've chosen Rust and the recently-stabilized async-std library for a variety of reasons. First, I like Rust. It's a good language. Second, async-std offers an asynchronous task-switching runtime much like Python's asyncio, or even JavaScript's Promise (even though things work a little differently under the hood). Long story short, it allows for easy concurrent programming with few resources and high performance. Everything I could want, really. It also comes with a variety of useful standard library API features reimplemented as asynchronous tasks.

For starters, I need to include async-std as a dependency. I'm also going to pull in anyhow for friendlier error handling.

Cargo.toml:


[package]
name = "fortune-pit"
version = "0.1.0"
edition = "2018"

[dependencies.async-std]
version = "1"
features = ["attributes"]

[dependencies.anyhow]
version = "1"

Now I've gotta actually write some code. I already said I wanted to listen for incoming TCP connections, so I'll start with a TcpListener.


use anyhow::Result;
use async_std::net::TcpListener;

#[async_std::main]
async fn main() -> Result<()> {
    let listener = TcpListener::bind("0.0.0.0:2222").await?;
    Ok(())
}

Better yet, I'll make the bind port configurable at runtime. In a live environment, it would be best to run this on port 22. But for testing purposes, as a non-root user, I'll run it on 2222. These changes will let me do that at will. I'll also print a nicer error message if anything goes wrong here.


use anyhow::{Context, Result};
use async_std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
use std::env;

#[async_std::main]
async fn main() -> Result<()> {
    let listener = TcpListener::bind(read_addr()?)
        .await
        .with_context(|| "tarpit: failed to bind TCP listener")?;
    Ok(())
}

fn read_addr() -> Result {
    let port = env::args()
        .nth(1)
        .map(|arg| arg.parse())
        .unwrap_or(Ok(22))
        .with_context(|| "tarpit: failed to parse bind port")?;

    Ok(SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), port))
}

There. Slightly more complicated, but much more convenient.

Now I need to actually do something with my TcpListener. I'll loop over incoming connections and open streams for all of them. Then I'll write something to those streams and close them.


use anyhow::{Context, Result};
use async_std::{
    io::prelude::*,
    net::{Ipv4Addr, SocketAddrV4, TcpListener},
    prelude::*,
};
use std::env;

#[async_std::main]
async fn main() -> Result<()> {
    let listener = TcpListener::bind(read_addr()?)
        .await
        .with_context(|| "tarpit: failed to bind TCP listener")?;

    let mut incoming = listener.incoming();
    while let Some(stream) = incoming.next().await {
        let mut stream = stream?;
        writeln!(stream, "Here's your fortune!").await?;
    }

    Ok(())
}

fn read_addr() -> Result {
    let port = env::args()
        .nth(1)
        .map(|arg| arg.parse())
        .unwrap_or(Ok(22))
        .with_context(|| "tarpit: failed to parse bind port")?;

    Ok(SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), port))
}

And it works! If I cargo run -- 2222, passing my port as the first argument, I get a very basic TCP server. I can test it with nc(1).

$ nc localhost 2222
Here's your fortune!
^C

Great. But not there yet. First of all, my client immediately receives a response. I want to keep feeding information to the client over and over until it gives up. I don't want it to time out waiting for nothing. I also want the user to pick what gets written.

So let's read in whatever the user wants to send along on STDIN. I thought this would be better than reading a file because it's really easy to send files to STDIN with pipes or redirection. It also lets you write the output of commands like fortune(6).



Improvements

The SSH RFC specifies that lines written for the banner cannot exceed 255 characters including carriage return and line feed. This program imposes no such restriction, although it would probably be fairly easy to break up incoming text into 255 character lines and write those one word at a time.

It's probably also worth noting that should any lines begin with SSH-, they're interpreted as SSH protocol version identifiers, causing the client and server to begin their authentication dance. Normally, unless the following data is valid, this results in a disconnect. In a worst-case scenario, if you pipe a completely legitimate protocol version, the client will successfully attempt authentication.