← SFTP: transferências e operação batch
09 / 12 · 60 MIN

SFTP sobre SSH e fases de falha

Observa transferências reais em loopback e distingue identidade do servidor, autenticação, subsistema e operações sobre ficheiros.

Segue as fases antes de mudar credenciais

SFTP usa uma ligação SSH, mas uma autenticação aceite ainda não demonstra que o protocolo de ficheiros arrancou. O cliente precisa de verificar o servidor, autenticar a conta, pedir o subsistema e negociar SFTP antes de executar put ou get. O runner desta aula executa essas fases com OpenSSH 10.5p1 e ficheiros sintéticos em loopback. Usa -S para selecionar explicitamente o executável SSH e -F /dev/null para evitar configuração pessoal. A identidade do host é fixada a partir da chave descartável gerada pelo próprio laboratório. Esta proveniência controlada não existe automaticamente numa migração real: a referência esperada tem de vir de um canal de confiança. Desenha as fases e associa cada mensagem ao ponto em que foi observada.

Compara três recusas antes do primeiro ficheiro

wrongHost apresenta uma chave incompatível com a referência local e termina sem autenticação aceite. wrongKey confirma o host, mas oferece uma chave de utilizador não autorizada. missingSubsystem é distinto: a conta autentica e o pedido do subsistema é recusado porque a configuração não o regista. Neste último caso, rodar a chave aceite não trata a causa observada. O cliente não mostra Remote version: 3, que identificaria negociação do protocolo SFTP e não uma versão comercial de OpenSSH. Compara os logs do cliente com a configuração efetiva e o log do daemon. Um único código de saída não explica todas estas fases; guarda o contexto suficiente para atribuir a investigação ao responsável certo.

Distingue autorização de conta e de operação

No controlo readOnly, duas sessões autenticam. Uma lê existing.bin e a outra tenta criar denied.bin. A leitura passa e a escrita falha, embora a conta tenha acesso de escrita à pasta no filesystem. A diferença é a opção -R do sftp-server. Não a confundas com -R no cliente sftp, que controla pedidos concorrentes. Outra experiência bloqueia rename e posix-rename: o upload termina, mas a publicação falha e conserva complete.part. O laboratório usa -d para escolher a pasta inicial. Essa opção não cria chroot nem isola a conta. As operações foram limitadas pelo código a ficheiros temporários, mas a aceitação de isolamento real precisa de configuração e testes próprios. Não amplifiques permissões para tentar ultrapassar uma recusa do subsistema.

Executa o código e conserva limites de evidência

Copia o código completo para run.py e usa os sete executáveis correspondentes indicados no guião da aula seguinte. O build executado usa OpenSSH 10.5p1, OpenSSL 3.6.1 e Python 3.13.1, sem PAM. A conta local não-root tem de poder autenticar; o runner não cria contas nem altera o serviço SSH do sistema. São iniciados oito processos temporários sshd e onze clientes SFTP, com 42 verificações. As chaves, configurações e ficheiros sintéticos são removidos no fim. O JSON guarda resultados e diagnósticos, nunca chaves privadas. Repete num contexto autorizado e compara fases, conteúdo e resultados, separando portas, caminhos e fingerprints efémeros. Estes testes não representam parceiro externo, chroot, interrupção de rede, durabilidade após falha ou consumidor financeiro. A execução não concede acesso a qualquer infraestrutura externa.

#!/usr/bin/env python3
"""Original SFTP over loopback SSH lab. Disposable keys, synthetic files, current local account, no PAM or chroot."""
import argparse,contextlib,hashlib,json,os,pwd,shlex,signal,socket,subprocess,sys,tempfile,time
from pathlib import Path

def main():
    p=argparse.ArgumentParser(description=__doc__)
    for name in ['ssh','sshd','sshd-session','sshd-auth','keygen','sftp','sftp-server']:p.add_argument('--'+name,required=True)
    p.add_argument('--output',default='sftp-transport-evidence.json');a=p.parse_args();user=pwd.getpwuid(os.getuid()).pw_name
    checks=[];observations={};configurations=[];sessions=[]
    def check(label,ok):assert ok,label;checks.append(label)
    def call(argv):
        x=subprocess.run(list(map(str,argv)),text=True,capture_output=True,timeout=15);assert x.returncode==0,(argv,x.stderr);return x.stdout+x.stderr
    version=call([a.ssh,'-V']).strip();assert 'OpenSSH_10.5p1' in version
    def sha(path):return hashlib.sha256(Path(path).read_bytes()).hexdigest()
    def quote(path):return '"'+str(path).replace('\\','\\\\').replace('"','\\"')+'"'
    with tempfile.TemporaryDirectory(prefix='dr-sftp-transport-')as temp:
        root=Path(temp);root.chmod(0o700)
        def key(name):
            f=root/name;call([a.keygen,'-q','-t','ed25519','-N','','-C','dr-disposable-'+name,'-f',f]);return f
        host=key('host');badhost=key('badhost');identity=key('identity');wrong=key('wrong')
        local=root/'local';local.mkdir();source=local/'source.bin';data=(b'DR-original-SFTP-delivery;version=2026-10\n'*4000)+bytes(range(256));source.write_bytes(data)
        @contextlib.contextmanager
        def daemon(label,server_options='',subsystem=True):
            folder=root/label;folder.mkdir();remote=folder/'remote';remote.mkdir();existing=remote/'existing.bin';existing.write_bytes(data)
            auth=folder/'authorized_keys';auth.write_text(Path(str(identity)+'.pub').read_text());auth.chmod(0o600)
            with socket.socket()as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
            command=shlex.quote(a.sftp_server)+' -e -l VERBOSE -d '+shlex.quote(str(remote))+(' '+server_options if server_options else'')
            conf=folder/'sshd.conf';conf.write_text(f'''ListenAddress 127.0.0.1
Port {port}
HostKey {host}
PidFile {folder}/pid
SshdSessionPath {a.sshd_session}
SshdAuthPath {a.sshd_auth}
AuthorizedKeysFile {auth}
AllowUsers {user}
AuthenticationMethods publickey
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PermitUserEnvironment no
PermitUserRC no
PermitTTY no
X11Forwarding no
AllowAgentForwarding no
DisableForwarding yes
StrictModes yes
UseDNS no
LogLevel VERBOSE
ForceCommand {command}
SetEnv ZDOTDIR={folder}
'''+('Subsystem sftp '+command+'\n'if subsystem else''))
            call([a.sshd,'-t','-f',conf]);effective=call([a.sshd,'-T','-f',conf]);configurations.append(dict(label=label,text=conf.read_text(),effective=effective))
            log=folder/'server.log'
            with log.open('w')as lf:
                proc=subprocess.Popen([a.sshd,'-D','-e','-f',str(conf)],stdout=lf,stderr=lf,start_new_session=True)
                try:
                    for _ in range(200):
                        assert proc.poll()is None,log.read_text()
                        if 'Server listening on 'in log.read_text():break
                        time.sleep(.02)
                    else:raise AssertionError('Server did not listen')
                    def transfer(name,lines,offered=identity,bad_host=False,cwd=local):
                        known=folder/(name+'.known_hosts');pub=Path(str(badhost if bad_host else host)+'.pub').read_text().split();known.write_text('[127.0.0.1]:'+str(port)+' '+pub[0]+' '+pub[1]+'\n')
                        args=[a.sftp,'-S',a.ssh,'-F','/dev/null','-vv','-b','-','-P',str(port),'-i',str(offered),'-o','BatchMode=yes','-o','IdentitiesOnly=yes','-o','IdentityAgent=none','-o','CertificateFile=none','-o','UserKnownHostsFile='+str(known),'-o','GlobalKnownHostsFile=/dev/null','-o','StrictHostKeyChecking=yes','-o','UpdateHostKeys=no','-o','PreferredAuthentications=publickey','-o','PasswordAuthentication=no','-o','KbdInteractiveAuthentication=no','-o','ControlMaster=no','-o','ControlPath=none','-o','ProxyCommand=none','-o','ProxyJump=none','-o','ConnectTimeout=5',user+'@127.0.0.1']
                        x=subprocess.run(args,input='\n'.join(lines)+'\n',text=True,capture_output=True,timeout=15,cwd=cwd)
                        result=dict(label=name,exit=x.returncode,stdout=x.stdout,stderr=x.stderr,batch=lines,args=args);sessions.append(result);return result
                    yield remote,transfer,log
                finally:
                    if proc.poll()is None:
                        os.killpg(proc.pid,signal.SIGTERM)
                        try:proc.wait(timeout=5)
                        except subprocess.TimeoutExpired:os.killpg(proc.pid,signal.SIGKILL);proc.wait(timeout=5)
                    check(label+': daemon stopped',proc.poll()is not None)
        with daemon('roundtrip')as(remote,run,log):
            final=remote/'final.bin';final.write_bytes(b'old-version');old=sha(final)
            staged=run('stage',[f'put {quote(source)} stage.part']);check('upload succeeds over SSH',staged['exit']==0);check('staged bytes match source',sha(remote/'stage.part')==sha(source));check('old final unchanged before publication',sha(final)==old)
            published=run('publish',['rename stage.part final.bin',f'get final.bin {quote(local/"roundtrip.bin")}']);check('publication and download succeed',published['exit']==0);check('roundtrip bytes match source',sha(local/'roundtrip.bin')==sha(source)==sha(final));check('staging name removed',not(remote/'stage.part').exists());check('actual SSH authentication observed','Authenticated to 'in staged['stderr']);check('SFTP version negotiation observed','Remote version: 3'in staged['stderr'])
            observations['roundtrip']=dict(stageExit=staged['exit'],publishExit=published['exit'],sourceHash=sha(source),finalHash=sha(final),roundtripHash=sha(local/'roundtrip.bin'),oldFinalHash=old,bytes=len(data),serverLog=log.read_text())
        for label,bad_host,offered in [('wrongHost',True,identity),('wrongKey',False,wrong)]:
            with daemon(label)as(remote,run,log):
                x=run(label,[f'put {quote(source)} rejected.bin'],offered,bad_host);check(label+': client fails',x['exit']!=0);check(label+': no upload',not(remote/'rejected.bin').exists());check(label+': no accepted authentication','Authenticated to 'not in x['stderr']);term='REMOTE HOST IDENTIFICATION HAS CHANGED'if bad_host else'Permission denied';check(label+': reason recorded',term in x['stderr']);observations[label]=dict(exit=x['exit'],fileCreated=False,serverLog=log.read_text())
        with daemon('missingSubsystem',subsystem=False)as(remote,run,log):
            x=run('missingSubsystem',['pwd']);check('missing subsystem: authentication succeeds','Authenticated to 'in x['stderr']);check('missing subsystem: request fails',x['exit']!=0 and 'subsystem request failed'in x['stderr']);check('missing subsystem: no protocol version','Remote version: 3'not in x['stderr']);observations['missingSubsystem']=dict(exit=x['exit'],authenticated=True,protocolStarted=False,serverLog=log.read_text())
        with daemon('readOnly','-R')as(remote,run,log):
            read=run('readOnlyGet',[f'get existing.bin {quote(local/"readonly.bin")}']);write=run('readOnlyPut',[f'put {quote(source)} denied.bin']);check('read-only: read succeeds',read['exit']==0 and sha(local/'readonly.bin')==sha(source));check('read-only: write rejected',write['exit']!=0 and not(remote/'denied.bin').exists());check('read-only: both authenticate',all('Authenticated to 'in x['stderr']for x in [read,write]));check('read-only: filesystem writable',os.access(remote,os.W_OK));observations['readOnly']=dict(readExit=read['exit'],writeExit=write['exit'],readHash=sha(local/'readonly.bin'),directoryWritable=True,serverLog=log.read_text())
        with daemon('renameDenied','-P rename,posix-rename')as(remote,run,log):
            x=run('renameDenied',[f'put {quote(source)} complete.part','rename complete.part ready.bin']);check('rename denied: batch fails',x['exit']!=0);check('rename denied: staging retained',sha(remote/'complete.part')==sha(source));check('rename denied: final absent',not(remote/'ready.bin').exists());observations['renameDenied']=dict(exit=x['exit'],stagingHash=sha(remote/'complete.part'),finalExists=False,serverLog=log.read_text())
        with daemon('relativeContext')as(remote,run,log):
            expected=local/'expected';stale=local/'stale';expected.mkdir();stale.mkdir();(expected/'delivery.csv').write_bytes(b'version,current\n');(stale/'delivery.csv').write_bytes(b'version,previous\n')
            x=run('relativeWrongSource',['put delivery.csv uploaded.csv'],cwd=stale);y=run('explicitSource',[f'put {quote(expected/"delivery.csv")} correct.csv'],cwd=stale)
            check('relative paths: both transfers succeed',x['exit']==y['exit']==0);check('relative paths: wrong local version uploaded',sha(remote/'uploaded.csv')==sha(stale/'delivery.csv')!=sha(expected/'delivery.csv'));check('relative paths: explicit path selects expected version',sha(remote/'correct.csv')==sha(expected/'delivery.csv'));observations['relativeContext']=dict(relativeExit=x['exit'],explicitExit=y['exit'],expectedHash=sha(expected/'delivery.csv'),uploadedHash=sha(remote/'uploaded.csv'),explicitHash=sha(remote/'correct.csv'),serverLog=log.read_text())
        with daemon('flushRequest')as(remote,run,log):
            x=run('flushRequest',[f'put -f {quote(source)} flushed.bin']);check('flush: transfer succeeds',x['exit']==0);check('flush: content matches',sha(remote/'flushed.bin')==sha(source));check('flush: fsync extension advertised','fsync@openssh.com'in x['stderr']);check('flush: request observed','Sending SSH2_FXP_EXTENDED(fsync@openssh.com)'in x['stderr']);observations['flushRequest']=dict(exit=x['exit'],contentHash=sha(remote/'flushed.bin'),serverLog=log.read_text(),powerLossTested=False)
    check('temporary files and keys removed',not root.exists())
    report=dict(sshVersion=version,checks=checks,observations=observations,sessions=sessions,configurations=configurations,runnerSha256=sha(__file__),binarySha256={n:sha(getattr(a,n.replace('-','_')))for n in ['ssh','sshd','sshd-session','sshd-auth','keygen','sftp','sftp-server']},scope='Eight loopback sshd processes and eleven SFTP client sessions with synthetic files and disposable keys. Current local account, no PAM, chroot, account isolation, external partner, power-loss durability, network interruption or business consumer. Starting directory is not a security boundary. Existing filesystem was only accessed inside the temporary experiment tree.')
    Path(a.output).write_text(json.dumps(report,indent=2).replace(user,'lab-account')+'\n');print(json.dumps(dict(experiments=len(observations),sessions=len(sessions),checks=len(checks),output=a.output)))
if __name__=='__main__':main()
NA PRÁTICA

Cais autentica depois da migração, mas o subsistema não arranca. A equipa compara a configuração e usa uma sessão nova para confirmar negociação e uma operação sintética.

Armadilhas comuns

Rodar uma chave já aceite, tratar -d como chroot, confundir opções do cliente e do servidor ou usar permissões globais para corrigir uma política read-only.

Tópicos relacionados: SSH e identidade · Permissões e políticas SFTP

Leva esta ideia contigo

Localiza a fase e o controlo que produziu a recusa. Um login aceite não confirma o subsistema nem autoriza todas as operações sobre ficheiros.

Criar conta

Referência: OpenSSH SFTP client · DR SFTP 2026-09; selected OpenSSH client, server and extension behavior