TSG CTF 2021にチームで参加しました。CTFの参加は人生で2回目です。
1問だけですが解けたので、解き方と解答を書きます。
[Misc]UB#
278pts(10 solves)
問題
Exception of C# is perfect, isn't it?
nc 34.146.161.123 2000
ubsharp.tar.gz
解き方
C#の問題です。最初にProof of Workが求められるので、表示されたコマンドを打って得た結果をそのまま送ります。
$ nc 34.146.161.123 2000
[Proof of Work]
Submit the token generated by `hashcash -mb27 mnbavtuokb`
1:27:211004:mnbavtuokb::
matched token: 1:27:211004:mnbavtuokb::
check: ok
read until 'END\n'
サーバ側で動いているプログラムのソースコードはubsharp.tar.gz
にあるので、中身を見てみます。
start.sh
が最初に呼び出されます。start.sh
ではProof of Workが終わった後にserver.py
を呼び出しているので、その中身を見てみます。
template_path = "./template.cs"
with open(template_path, 'r') as f:
template_txt = f.read()
print(hello_output)
tmp = ""
while tmp != "END":
user_input += tmp+'\n'
tmp = input()
if len(user_input) > PROGRAM_MAX_LEN:
print("too long!!")
sys.exit()
if not sanitizer(user_input):
print("Evil input")
sys.exit()
s = Template(template_txt)
user_output = (s.substitute(user_input=user_input, password=password))
上記の部分で、C#のプログラムが書かれたtemplate.cs
を読み込んで、\$user_inputをユーザが入力した文字列、\$passwordを"予め生成したランダムな文字列"に置き換えています。server.py
は、上記の処理の後、template.cs
をコンパイル・実行し、実行結果として"予め生成したランダムな文字列"が出力されれば、フラグを得られます。
template.cs
は次のような中身になっています。
namespace test
{
class Program
{
public readonly System.Collections.Generic.List<int> a = new System.Collections.Generic.List<int>();
public Program(){
a.Add(1);
a.Add(2);
}
public void Run(){
System.Action<int> b = x=>{
$user_input
};
try {
a.ForEach(b);
if(a.Count!=2||(a.Count>0&&a[0]!=1)){
System.Console.WriteLine("$password");
}
} catch (System.Exception e) {
System.Console.WriteLine("failed");
}
}
static void Main(string[] args)
{
var p = new Program();
p.Run();
}
}
}
List型の変数a
があり、ForEachが行われている間にa
のアイテムの数を変えるか、a[0]
を1以外にすれば、$passwordが出力され、フラグが得られます。Foreach(Action action)とすることで、各要素ごとにActionが実行されます。つまり、
System.Action<int> b = x=>{
$user_input
};
要素1つにつき1回$user_inputに書かれたプログラムが実行されるということです。(x
に要素の中身が代入される)
単純に$user_inputを
a[0]=2;
のようにすれば良いのではという考えが最初に浮かびますが、C#ではForeachをしているときにそのListの要素を変更するとInvalidOperationException
が発生してしまいます。(template.cs
ではこの例外エラーがtry-catchで補足され、パスワードの代わりにfailed
が出力されてしまう)
そのため、この例外を発生させずにa
を変更するように、$user_inputに書くプログラムを考えないといけません。
また、server.py
は入力した$user_inputに対して、以下の単語が入っているとrejectするようにサニタイジングが入っています。
blacklist_words = ['`', '@', '$', '\\', '#', '"', "'", "//", "/*", "using", "unsafe",
"Reflection", "IO", "File", "Console", "Net",
"Accessibility", "Microsoft", "System", "UI"]
そのため、$user_inputを
System.Console.WriteLine("$password");
にしたり、メモリを直接触ったり、Refrectionでprivate変数を触ったり、というのは難しそうです。
では、どのようにListの変更を検知してInvalidOperationException
を出しているのか見ていきましょう。
ググってみるとListのソースコードがあったのでそれを見てみます。
Foreachの中身は以下のようになっていました。
public void ForEach(Action<T> action) {
if( action == null) {
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.match);
}
Contract.EndContractBlock();
int version = _version;
for(int i = 0 ; i < _size; i++) {
if (version != _version && BinaryCompatibility.TargetsAtLeast_Desktop_V4_5) {
break;
}
action(_items[i]);
}
if (version != _version && BinaryCompatibility.TargetsAtLeast_Desktop_V4_5)
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumFailedVersion);
}
最初に_version
という変数をversion
に記録しておいて、各要素に対してaction
を呼び出した後、_version
とversion
が一致していないとInvalidOperationException
を出すようになってました。_version
はint型なんですね。
では、_version
がどのように設定されてるのかを調べていきます。
public T this[int index] {
get {
// Following trick can reduce the range check by one
if ((uint) index >= (uint)_size) {
ThrowHelper.ThrowArgumentOutOfRangeException();
}
Contract.EndContractBlock();
return _items[index];
}
set {
if ((uint) index >= (uint)_size) {
ThrowHelper.ThrowArgumentOutOfRangeException();
}
Contract.EndContractBlock();
_items[index] = value;
_version++;
}
}
public void Add(T item) {
if (_size == _items.Length) EnsureCapacity(_size + 1);
_items[_size++] = item;
_version++;
}
public void Clear() {
if (_size > 0)
{
Array.Clear(_items, 0, _size); // Don't need to doc this but we clear the elements so that the gc can reclaim the references.
_size = 0;
}
_version++;
}
Listを変更するメソッドをいくつか抜粋しました。いずれも_version
を単純にインクリメントさせていました。
ということは、_version
をオーバーフローさせて、Foreachが始まる前に記録されたversion
に合わせれば変更の検知を回避できそうです。
なので、$user_inputを以下のようにしてみます。
解答
if(x==1) for (long i = 0; i <= uint.MaxValue; i++) { a[0] = 2; }
uintの最大値+1だけa[0]=2
するプログラムです。これだけでも結構実行に時間がかかるので、念のためif(x==1)
を追加して、Listの最初の要素に対してだけ実行するようにしました。
$ nc 34.146.161.123 2000
[Proof of Work]
Submit the token generated by `hashcash -mb27 mnbavtuokb`
1:27:211004:mnbavtuokb::
matched token: 1:27:211004:mnbavtuokb::
check: ok
read until 'END\n'
if(x==1) for (long i = 0; i <= uint.MaxValue; i++) { a[0] = 2; }
END
following program is run
namespace test
{
class Program
{
public readonly System.Collections.Generic.List<int> a = new System.Collections.Generic.List<int>();
public Program(){
a.Add(1);
a.Add(2);
}
public void Run(){
System.Action<int> b = x=>{
if(x==1) for (long i = 0; i <= uint.MaxValue; i++) { a[0] = 2; }
};
try {
a.ForEach(b);
if(a.Count!=2||(a.Count>0&&a[0]!=1)){
System.Console.WriteLine("hdzybrdhxxdaoggnymbjbntkxuymcnzaxslxubxxwdyvmbwbdqmduhovxpnj");
}
} catch (System.Exception e) {
System.Console.WriteLine("failed");
}
}
static void Main(string[] args)
{
var p = new Program();
p.Run();
}
}
}
Congratulations! TSGCTF{eXc6ptiOn_1s_w0rk1ng_ExcePt_uNdeFineD_beh@vi0uR}