ruby_apk 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color --format d
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ source "http://rubygems.org"
2
+ # Add dependencies required to use your gem here.
3
+ # Example:
4
+ # gem "activesupport", ">= 2.3.5"
5
+ gem "rubyzip"
6
+
7
+ # Add dependencies to develop your gem here.
8
+ # Include everything needed to run rake, tests, features, etc.
9
+ group :development do
10
+ gem "rspec", "~> 2.11.0"
11
+ gem "bundler", "~> 1.1.5"
12
+ gem "jeweler", "~> 1.6.4"
13
+ gem "yard", require: false
14
+ gem "redcarpet"
15
+ gem "simplecov", require: false
16
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,38 @@
1
+ GEM
2
+ remote: http://rubygems.org/
3
+ specs:
4
+ diff-lcs (1.1.3)
5
+ git (1.2.5)
6
+ jeweler (1.6.4)
7
+ bundler (~> 1.0)
8
+ git (>= 1.2.5)
9
+ rake
10
+ multi_json (1.3.6)
11
+ rake (0.9.2.2)
12
+ redcarpet (2.2.2)
13
+ rspec (2.11.0)
14
+ rspec-core (~> 2.11.0)
15
+ rspec-expectations (~> 2.11.0)
16
+ rspec-mocks (~> 2.11.0)
17
+ rspec-core (2.11.1)
18
+ rspec-expectations (2.11.2)
19
+ diff-lcs (~> 1.1.3)
20
+ rspec-mocks (2.11.1)
21
+ rubyzip (0.9.9)
22
+ simplecov (0.6.4)
23
+ multi_json (~> 1.0)
24
+ simplecov-html (~> 0.5.3)
25
+ simplecov-html (0.5.3)
26
+ yard (0.8.2.1)
27
+
28
+ PLATFORMS
29
+ ruby
30
+
31
+ DEPENDENCIES
32
+ bundler (~> 1.1.5)
33
+ jeweler (~> 1.6.4)
34
+ redcarpet
35
+ rspec (~> 2.11.0)
36
+ rubyzip
37
+ simplecov
38
+ yard
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ (The MIT License)
2
+
3
+ Copyright (c) 2012 Securebrain
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # ruby_apk
2
+ Android Apk static analysis library for Ruby.
3
+
4
+ ## Requirements
5
+ - ruby(>=1.9.x)
6
+ - rubyzip gem(>=0.9.9)
7
+
8
+ ## Install
9
+ $ gem install ruby_apk
10
+
11
+ ## Usage
12
+ ### Initialize
13
+ require 'ruby_apk'
14
+ apk = Android::Apk.new('sample.apk') # set apk file path
15
+
16
+ ### Apk
17
+ #### Listing files in Apk
18
+ # listing files in apk
19
+ apk = Android::Apk.new('sample.apk')
20
+ apk.each_file do |name, data|
21
+ puts "#{name}: #{data.size}bytes" # puts file name and data size
22
+ end
23
+
24
+ #### Find files in Apk
25
+ apk = Android::Apk.new('sample.apk')
26
+ elf_files = apk.find{|name, data| data[0..3] == [0x7f, 0x45, 0x4c, 0x46] } # ELF magic number
27
+
28
+ ### Manifest
29
+ #### Get readable xml
30
+ apk = Android::Apk.new('sample.apk')
31
+ manifest = apk.manifest
32
+ puts manifest.to_xml
33
+
34
+ #### Listing components and permissions
35
+ apk = Android::Apk.new('sample.apk')
36
+ manifest = apk.manifest
37
+ # listing components
38
+ manifest.components.each do |c| # 'c' is Android::Manifest::Component object
39
+ puts "#{c.type}: #{c.name}"
40
+ c.intent_filters.each do |filter|
41
+ puts "\t#{filter.type}"
42
+ end
43
+ end
44
+
45
+ # listing use-permission tag
46
+ manifest.use_permissions.each do |permission|
47
+ puts permission
48
+ end
49
+
50
+ ### Resource
51
+ #### Extract resource strings from apk
52
+ apk = Android::Apk.new('sample.apk')
53
+ rsc = apk.resource
54
+ rsc.strings.each do |str|
55
+ puts str
56
+ end
57
+
58
+ #### Parse resource file directly
59
+ rsc_data = File.open('resources.arsc', 'rb').read{|f| f.read }
60
+ rsc = Android::Resource.new(rsc_data)
61
+
62
+ ### Dex
63
+ #### Extract dex information
64
+ apk = Android::Apk.new('sample.apk')
65
+ dex = apk.dex
66
+ # listing string table in dex
67
+ dex.strings do |str|
68
+ puts str
69
+ end
70
+
71
+ # listing all class names
72
+ dex.classes do |cls| # cls is Android::Dex::ClassInfo
73
+ puts cls.name
74
+ end
75
+
76
+ #### Parse dex file directly
77
+ dex_data = File.open('classes.dex','rb').read{|f| f.read }
78
+ dex = Android::Dex.new(dex_data)
79
+
80
+
81
+ ## ChangeLog
82
+ ### 0.4.0
83
+ * add resource parser
84
+ * enhance dex parser
85
+
86
+ ### 0.3.0
87
+ * add and change name space
88
+ * add Android::Utils module and some util methods
89
+ * add Apk#entry, Apk#each_entry, and Apk#time methods,
90
+
91
+ ### 0.2.0
92
+ * update documents
93
+ * add Apk::Dex#each_strings, Apk::Dex#each_class_names
94
+
95
+ ### 0.1.2
96
+ * fix bug(improve android binary xml parser)
97
+
98
+ ### 0.1.1
99
+ * fix bug(failed to initialize Apk::Manifest::Meta class)
100
+ * replace iconv to String#encode(for ruby1.9)
101
+
102
+
103
+ ## Copyright
104
+
105
+ Copyright (c) 2012 SecureBrain. See LICENSE.txt for further details.
106
+
data/Rakefile ADDED
@@ -0,0 +1,43 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'bundler'
5
+ begin
6
+ Bundler.setup(:default, :development)
7
+ rescue Bundler::BundlerError => e
8
+ $stderr.puts e.message
9
+ $stderr.puts "Run `bundle install` to install missing gems"
10
+ exit e.status_code
11
+ end
12
+ require 'rake'
13
+
14
+ require 'jeweler'
15
+ Jeweler::Tasks.new do |gem|
16
+ # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
17
+ gem.name = "ruby_apk"
18
+ gem.homepage = "http://www.securebrain.co.jp/"
19
+ gem.license = "MIT"
20
+ gem.summary = %Q{static analysis tool for android apk}
21
+ gem.description = %Q{static analysis tool for android apk}
22
+ gem.email = "info@securebrain.co.jp"
23
+ gem.authors = ["SecureBrain"]
24
+ # dependencies defined in Gemfile
25
+ end
26
+ Jeweler::RubygemsDotOrgTasks.new
27
+
28
+ require 'rspec/core'
29
+ require 'rspec/core/rake_task'
30
+ RSpec::Core::RakeTask.new(:spec) do |spec|
31
+ spec.pattern = FileList['spec/**/*_spec.rb']
32
+ end
33
+
34
+
35
+ task :default => :spec
36
+
37
+ require 'yard'
38
+ require 'yard/rake/yardoc_task'
39
+ YARD::Rake::YardocTask.new do |t|
40
+ t.files = ['lib/**/*.rb']
41
+ t.options = []
42
+ t.options << '--debug' << '--verbose' if $trace
43
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.4.0
@@ -0,0 +1,155 @@
1
+ require 'zip/zip' # need rubyzip gem -> doc: http://rubyzip.sourceforge.net/
2
+ require 'digest/md5'
3
+ require 'digest/sha1'
4
+ require 'digest/sha2'
5
+
6
+ module Android
7
+ class NotApkFileError < StandardError; end
8
+ class NotFoundError < StandardError; end
9
+
10
+ # apk object class
11
+ class Apk
12
+
13
+ # @return [String] apk file path
14
+ attr_reader :path
15
+ # @return [Android::Manifest] manifest instance
16
+ # @return [nil] when parsing manifest is failed.
17
+ attr_reader :manifest
18
+ # @return [Android::Dex] dex instance
19
+ # @return [nil] when parsing dex is failed.
20
+ attr_reader :dex
21
+ # @return [String] binary data of apk
22
+ attr_reader :bindata
23
+ # @return [Resource] resouce data
24
+ # @return [nil] when parsing resource is failed.
25
+ attr_reader :resource
26
+
27
+ # AndroidManifest file name
28
+ MANIFEST = 'AndroidManifest.xml'
29
+ # dex file name
30
+ DEX = 'classes.dex'
31
+ # resource file name
32
+ RESOURCE = 'resources.arsc'
33
+
34
+ # create new apk object
35
+ # @param [String] filepath apk file path
36
+ # @raise [Android::NotFoundError] path file does'nt exist
37
+ # @raise [Android::NotApkFileError] path file is not Apk file.
38
+ def initialize(filepath)
39
+ @path = filepath
40
+ raise NotFoundError, "'#{filepath}'" unless File.exist? @path
41
+ begin
42
+ @zip = Zip::ZipFile.open(@path)
43
+ rescue Zip::ZipError => e
44
+ raise NotApkFileError, e.message
45
+ end
46
+
47
+ @bindata = File.open(@path, 'rb') {|f| f.read }
48
+ @bindata.force_encoding(Encoding::ASCII_8BIT)
49
+ raise NotApkFileError, "manifest file is not found." if @zip.find_entry(MANIFEST).nil?
50
+ begin
51
+ @manifest = Android::Manifest.new(self.file(MANIFEST))
52
+ rescue => e
53
+ $stderr.puts "failed to parse manifest:#{e}"
54
+ #$stderr.puts e.backtrace
55
+ end
56
+ begin
57
+ @dex = Android::Dex.new(self.file(DEX))
58
+ rescue => e
59
+ $stderr.puts "failed to parse dex:#{e}"
60
+ #$stderr.puts e.backtrace
61
+ end
62
+ begin
63
+ @resource = Android::Resource.new(self.file(RESOURCE))
64
+ rescue => e
65
+ $stderr.puts "failed to parse resource:#{e}"
66
+ #$stderr.puts e.backtrace
67
+ end
68
+ end
69
+
70
+ # return apk file size
71
+ # @return [Integer] bytes
72
+ def size
73
+ @bindata.size
74
+ end
75
+
76
+ # return hex digest string of apk file
77
+ # @param [Symbol] type hash digest type(:sha1, sha256, :md5)
78
+ # @return [String] hex digest string
79
+ # @raise [ArgumentError] type is knknown type
80
+ def digest(type = :sha1)
81
+ case type
82
+ when :sha1
83
+ Digest::SHA1.hexdigest(@bindata)
84
+ when :sha256
85
+ Digest::SHA256.hexdigest(@bindata)
86
+ when :md5
87
+ Digest::MD5.hexdigest(@bindata)
88
+ else
89
+ raise ArgumentError
90
+ end
91
+ end
92
+
93
+ # returns date of AndroidManifest.xml as Apk date
94
+ # @return [Time]
95
+ def time
96
+ entry(MANIFEST).time
97
+ end
98
+
99
+ # @yield [name, data]
100
+ # @yieldparam [String] name file name in apk
101
+ # @yieldparam [String] data file data in apk
102
+ def each_file
103
+ @zip.each do |entry|
104
+ next unless entry.file?
105
+ yield entry.name, @zip.read(entry)
106
+ end
107
+ end
108
+
109
+ # find and return binary data with name
110
+ # @param [String] name file name in apk(fullpath)
111
+ # @return [String] binary data
112
+ # @raise [NotFoundError] when 'name' doesn't exist in the apk
113
+ def file(name) # get data by entry name(path)
114
+ @zip.read(entry(name))
115
+ end
116
+
117
+ # @yield [entry]
118
+ # @yieldparam [Zip::Entry] entry zip entry
119
+ def each_entry
120
+ @zip.each do |entry|
121
+ next unless entry.file?
122
+ yield entry
123
+ end
124
+ end
125
+
126
+ # find and return zip entry with name
127
+ # @param [String] name file name in apk(fullpath)
128
+ # @return [Zip::ZipEntry] zip entry object
129
+ # @raise [NotFoundError] when 'name' doesn't exist in the apk
130
+ def entry(name)
131
+ entry = @zip.find_entry(name)
132
+ raise NotFoundError, "'#{name}'" if entry.nil?
133
+ return entry
134
+ end
135
+
136
+ # find files which is matched with block condition
137
+ # @yield [name, data] find condition
138
+ # @yieldparam [String] name file name in apk
139
+ # @yieldparam [String] data file data in apk
140
+ # @yieldreturn [Array] Array of matched entry name
141
+ # @return [Array] Array of matched entry name
142
+ # @example
143
+ # apk = Apk.new(path)
144
+ # elf_files = apk.find { |name, data| data[0..3] == [0x7f, 0x45, 0x4c, 0x46] } # ELF magic number
145
+ def find(&block)
146
+ found = []
147
+ self.each_file do |name, data|
148
+ ret = block.call(name, data)
149
+ found << name if ret
150
+ end
151
+ found
152
+ end
153
+ end
154
+ end
155
+
@@ -0,0 +1,178 @@
1
+ require 'rexml/document'
2
+ require 'stringio'
3
+
4
+
5
+ module Android
6
+ # binary AXML parser
7
+ # @see https://android.googlesource.com/platform/frameworks/base.git Android OS frameworks source
8
+ # @note
9
+ # refer to Android OS framework code:
10
+ #
11
+ # /frameworks/base/include/androidfw/ResourceTypes.h,
12
+ #
13
+ # /frameworks/base/libs/androidfw/ResourceTypes.cpp
14
+ class AXMLParser
15
+ # axml parse error
16
+ class ReadError < StandardError; end
17
+
18
+ TAG_START_DOC = 0x00100100
19
+ TAG_END_DOC = 0x00100101
20
+ TAG_START = 0x00100102
21
+ TAG_END = 0x00100103
22
+ TAG_TEXT = 0x00100104
23
+ TAG_CDSECT = 0x00100105
24
+ TAG_ENTITY_REF= 0x00100106
25
+
26
+ VAL_TYPE_NULL =0
27
+ VAL_TYPE_REFERENCE =1
28
+ VAL_TYPE_ATTRIBUTE =2
29
+ VAL_TYPE_STRING =3
30
+ VAL_TYPE_FLOAT =4
31
+ VAL_TYPE_DIMENSION =5
32
+ VAL_TYPE_FRACTION =6
33
+ VAL_TYPE_INT_DEC =16
34
+ VAL_TYPE_INT_HEX =17
35
+ VAL_TYPE_INT_BOOLEAN =18
36
+ VAL_TYPE_INT_COLOR_ARGB8 =28
37
+ VAL_TYPE_INT_COLOR_RGB8 =29
38
+ VAL_TYPE_INT_COLOR_ARGB4 =30
39
+ VAL_TYPE_INT_COLOR_RGB4 =31
40
+
41
+ # @return [Array<String>] strings defined in axml
42
+ attr_reader :strings
43
+
44
+ # @param [String] axml binary xml data
45
+ def initialize(axml)
46
+ @io = StringIO.new(axml, "rb")
47
+ @strings = []
48
+ end
49
+
50
+ # parse binary xml
51
+ # @return [REXML::Document]
52
+ def parse
53
+ @doc = REXML::Document.new
54
+ @doc << REXML::XMLDecl.new
55
+
56
+ @num_str = word(4*4)
57
+ @xml_offset = word(3*4)
58
+
59
+ @parents = [@doc]
60
+ @ns = []
61
+ parse_strings
62
+ parse_tags
63
+ @doc
64
+ end
65
+
66
+
67
+ private
68
+ # read one word(4byte) as integer
69
+ # @param [Integer] offset offset from top position. current position is used if ofset is nil
70
+ # @return [Integer] little endian word value
71
+ def word(offset=nil)
72
+ @io.pos = offset unless offset.nil?
73
+ @io.read(4).unpack("V")[0]
74
+ end
75
+
76
+ # read 2byte as short integer
77
+ # @param [Integer] offset offset from top position. current position is used if ofset is nil
78
+ # @return [Integer] little endian unsign short value
79
+ def short(offset)
80
+ @io.pos = offset unless offset.nil?
81
+ @io.read(2).unpack("v")[0]
82
+ end
83
+
84
+ # parse string table
85
+ def parse_strings
86
+ sit_off = 0x24 # string index table offset
87
+ st_off = sit_off + @num_str * 4 # string table offset
88
+ @strings = []
89
+ @num_str.times do |i|
90
+ pos = st_off + word(sit_off + (4 * i)) # get position from string index table
91
+ len = short(pos) # read string length(not bytes)
92
+ str = @io.read(len*2) # read string(UTF-16LE)
93
+ str.force_encoding(Encoding::UTF_16LE)
94
+ @strings[i] = str.encode(Encoding::UTF_8)
95
+ end
96
+ end
97
+
98
+ # parse tag
99
+ def parse_tags
100
+ # skip until START_TAG
101
+ pos = @xml_offset
102
+ pos += 4 until (word(pos) == TAG_START) #ugh!
103
+ @io.pos -= 4
104
+
105
+ # read tags
106
+ #puts "start tag parse: %d(%#x)" % [@io.pos, @io.pos]
107
+ until @io.eof?
108
+ last_pos = @io.pos
109
+ tag, tag1, line, tag3, ns_id, name_id = @io.read(4*6).unpack("V*")
110
+ case tag
111
+ when TAG_START
112
+ tag6, num_attrs, tag8 = @io.read(4*3).unpack("V*")
113
+ elem = REXML::Element.new(@strings[name_id])
114
+ #puts "start tag %d(%#x): #{@strings[name_id]} attrs:#{num_attrs}" % [last_pos, last_pos]
115
+ @parents.last.add_element elem
116
+ num_attrs.times do
117
+ key, val = parse_attribute
118
+ elem.add_attribute(key, val)
119
+ end
120
+ @parents.push elem
121
+ when TAG_END
122
+ @parents.pop
123
+ when TAG_END_DOC
124
+ break
125
+ when TAG_TEXT
126
+ text = REXML::Text.new(@strings[ns_id])
127
+ @parents.last.text = text
128
+ dummy = @io.read(4*1).unpack("V*") # skip 4bytes
129
+ when TAG_START_DOC, TAG_CDSECT, TAG_ENTITY_REF
130
+ # not implemented yet.
131
+ else
132
+ raise ReadError, "pos=%d(%#x)[tag:%#x]" % [last_pos, last_pos, tag]
133
+ end
134
+ end
135
+ end
136
+
137
+ # parse attribute of a element
138
+ def parse_attribute
139
+ ns_id, name_id, val_str_id, flags, val = @io.read(4*5).unpack("V*")
140
+ key = @strings[name_id]
141
+ unless ns_id == 0xFFFFFFFF
142
+ ns = @strings[ns_id]
143
+ prefix = ns.sub(/.*\//,'')
144
+ unless @ns.include? ns
145
+ @ns << ns
146
+ @doc.root.add_namespace(prefix, ns)
147
+ end
148
+ key = "#{prefix}:#{key}"
149
+ end
150
+ value = convert_value(val_str_id, flags, val)
151
+ return key, value
152
+ end
153
+
154
+
155
+ def convert_value(val_str_id, flags, val)
156
+ unless val_str_id == 0xFFFFFFFF
157
+ value = @strings[val_str_id]
158
+ else
159
+ type = flags >> 24
160
+ case type
161
+ when VAL_TYPE_NULL
162
+ value = nil
163
+ when VAL_TYPE_REFERENCE
164
+ value = "@%#x" % val # refered resource id.
165
+ when VAL_TYPE_INT_DEC
166
+ value = val
167
+ when VAL_TYPE_INT_HEX
168
+ value = "%#x" % val
169
+ when VAL_TYPE_INT_BOOLEAN
170
+ value = val != 0xFFFFFFFE ? true : false # ugh! is it ok??
171
+ else
172
+ value = "[%#x, flag=%#x]" % [val, flags]
173
+ end
174
+ end
175
+ end
176
+ end
177
+
178
+ end
@@ -0,0 +1,74 @@
1
+
2
+ module Android
3
+ class Dex
4
+ # access flag object
5
+ class AccessFlag
6
+ # @return [Integer] flag value
7
+ attr_reader :flag
8
+ def initialize(flag)
9
+ @flag = flag
10
+ end
11
+ end
12
+
13
+ # access flag object for class in dex
14
+ class ClassAccessFlag < AccessFlag
15
+ ACCESSORS = [
16
+ {value:0x1, name:'public'},
17
+ {value:0x2, name:'private'},
18
+ {value:0x4, name:'protected'},
19
+ {value:0x8, name:'static'},
20
+ {value:0x10, name:'final'},
21
+ {value:0x20, name:'synchronized'},
22
+ {value:0x40, name:'volatile'},
23
+ {value:0x80, name:'transient'},
24
+ {value:0x100, name:'native'},
25
+ {value:0x200, name:'interface'},
26
+ {value:0x400, name:'abstract'},
27
+ {value:0x800, name:'strict'},
28
+ {value:0x1000, name:'synthetic'},
29
+ {value:0x2000, name:'annotation'},
30
+ {value:0x4000, name:'enum'},
31
+ #{value:0x8000, name:'unused'},
32
+ {value:0x10000, name:'constructor'},
33
+ {value:0x20000, name:'declared-synchronized'},
34
+ ]
35
+
36
+ # convert access flag to string
37
+ # @return [String]
38
+ def to_s
39
+ ACCESSORS.select{|e| ((e[:value] & @flag) != 0) }.map{|e| e[:name] }.join(' ')
40
+ end
41
+ end
42
+
43
+ # access flag object for method in dex
44
+ class MethodAccessFlag < AccessFlag
45
+ ACCESSORS = [
46
+ {value: 0x1, name:'public'},
47
+ {value: 0x2, name:'private'},
48
+ {value: 0x4, name:'protected'},
49
+ {value: 0x8, name:'static'},
50
+ {value: 0x10, name:'final'},
51
+ {value: 0x20, name:'synchronized'},
52
+ {value: 0x40, name:'bridge'},
53
+ {value: 0x80, name:'varargs'},
54
+ {value: 0x100, name:'native'},
55
+ {value: 0x200, name:'interface'},
56
+ {value: 0x400, name:'abstract'},
57
+ {value: 0x800, name:'strict'},
58
+ {value: 0x1000, name:'synthetic'},
59
+ {value: 0x2000, name:'annotation'},
60
+ {value: 0x4000, name:'enum'},
61
+ #{value: 0x8000, name:'unused'},
62
+ {value: 0x10000, name:'constructor'},
63
+ {value: 0x20000, name:'declared-synchronized'},
64
+ ]
65
+ # convert access flag to string
66
+ # @return [String]
67
+ def to_s
68
+ ACCESSORS.select{|e| ((e[:value] & @flag) != 0) }.map{|e| e[:name] }.join(' ')
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+